Compare commits

...
55 Commits
Author SHA1 Message Date
github-actions[bot] 88fa230e10 Update AppImage beta build (2026-11-05 19:26:42) 2026-11-05 19:26:42 +00:00
MacRimi 795f77f76f fix(oci): write the Nextcloud MariaDB bootstrap as the application user 2026-11-05 20:14:39 +01:00
MacRimi ecd94b45ec fix(oci): keep Tailscale starting once its web client is on 2026-11-05 20:09:40 +01:00
ProxMenuxBot eea782e4d1 chore(lang): auto-rebuild translation cache
Source: dc18463
Triggered by: push
2026-11-05 18:51:08 +00:00
MacRimiandGitHub dc18463965 Merge pull request #590 from jcastro/feature/oci-nextcloud-mariadb
feat(oci): add LinuxServer Nextcloud with MariaDB and Redis
2026-11-05 19:50:06 +01:00
MacRimi 256d1d911d fix(oci): generate the Nextcloud MariaDB administrator and keep its umask private
- The administrator password is generated at install and written to
  autoconfig.php, so Nextcloud installs itself on the first request and
  the setup page cannot be claimed by anyone else. The summary shows it.
- The private umask applies to the bootstrap only, not to /init.
- The bootstrap creates its files without following links and gives
  them to the application user with lchown.
- The startup check leaves out the default port of its scheme, so an
  application that trusts the host of its first request, like
  Nextcloud, opens from the LAN address.

Installed on a Proxmox host: Nextcloud 35.0.2 installs with the
generated administrator, opens from its LAN address and accepts a
WebDAV upload; the application runs with umask 0022.
2026-11-05 19:48:39 +01:00
MacRimi 745896e617 Merge develop into feature/oci-nextcloud-mariadb 2026-11-05 19:21:29 +01:00
MacRimi 13ac55ac82 fix(monitor): show a light Hermes logo in the dark theme 2026-11-05 19:20:37 +01:00
ProxMenuxBot 683595aefc chore(lang): auto-rebuild translation cache
Source: 3d19c93
Triggered by: push
2026-11-05 18:13:08 +00:00
MacRimiandGitHub 3d19c93c02 Merge pull request #468 from jcastro/feature/usbip-ssh-sharing
Add USB/IP device sharing over SSH
2026-11-05 19:11:55 +01:00
MacRimi 1753d3aaa5 fix(usbip): remove the transport account on uninstall and refuse USB NICs
- Uninstall removes the proxmenux-usbip account and its home when
  ProxMenux created them, so a reinstall does not revive old client keys.
- The client tunnel port can be bound again while earlier connections
  are in TIME_WAIT; restarting a client no longer waits a minute.
- USB network adapters are not offered: on many mini PCs one is the
  management link of the host.
- The share dialog explains the exceptional case that needs a reboot.
- The hardware menu keeps its name, so the scripts and docs that point
  to it stay correct.
2026-11-05 19:10:49 +01:00
MacRimi 7783f9a2ec Merge develop into feature/usbip-ssh-sharing 2026-11-05 19:04:00 +01:00
MacRimiandGitHub e30c592217 Merge pull request #595 from jcastro/feature/monitor-ovs-topology
feat(monitor): show Open vSwitch network topology
2026-11-05 18:46:54 +01:00
MacRimi 3090f1d772 fix(monitor): place guests behind the firewall on their OVS bridge
On an OVS bridge Proxmox links the firewall bridge fwbrNiM through its
own internal port fwlnNoM and creates no fwprNpM peer, so the guest was
shown on fwbrNiM. Check both links. Verified on an OVS 3.5 host with a
firewalled container.
2026-11-05 18:40:33 +01:00
MacRimi 163317f727 Merge develop into feature/monitor-ovs-topology 2026-11-05 18:40:33 +01:00
MacRimiandGitHub ce8ccab4de Merge pull request #596 from jcastro/feature/monitor-native-arm64-build
feat(monitor): add native ARM64 AppImage build validation
2026-11-05 18:21:10 +01:00
MacRimiandGitHub 202557f540 Merge pull request #594 from jcastro/fix/provider-model-availability
fix(ai): refresh Groq ordering and unavailable OpenRouter models
2026-11-05 18:20:32 +01:00
MacRimi d4525fb13e fix(oci): keep a device GID changed in Proxmox since the last operation 2026-11-05 18:19:49 +01:00
MacRimi 2d1d7bcc7b fix(build): pin the ARM AppImage runtime and align two translations
appimagetool 1.9.1 embeds the unpinned continuous runtime unless one
is given. Use the 20251108 release checked by its digest. The package
index refresh no longer fails the job on a broken runner repository.
2026-11-05 18:11:29 +01:00
MacRimi 7a73fa03f1 Merge develop into feature/monitor-native-arm64-build 2026-11-05 18:10:41 +01:00
MacRimi 9d6c1edbc7 ci: run the AI model availability tests in Feature tests 2026-11-05 18:10:23 +01:00
MacRimi c2fd894083 Merge develop into fix/provider-model-availability 2026-11-05 18:10:05 +01:00
ProxMenuxBot cd6b331a84 chore(lang): auto-rebuild translation cache
Source: 50a1d05
Triggered by: push
2026-11-05 17:09:21 +00:00
MacRimiandGitHub 50a1d050bc Merge pull request #601 from jcastro/fix/oci-preserve-container-device-gid
fix(oci): preserve recorded container device GIDs
2026-11-05 18:07:24 +01:00
MacRimiandGitHub bb697c4c23 Merge pull request #598 from jcastro/fix/oci-private-image-import
fix(oci): import private cached images with restrictive caller permissions
2026-11-05 18:06:52 +01:00
MacRimiandGitHub 41cc933e0a Merge pull request #591 from jcastro/fix/oci-stack-https-readiness
fix(oci): align stack HTTPS readiness with the single-container installer
2026-11-05 18:06:18 +01:00
MacRimiandGitHub b37e6610d1 Merge pull request #597 from jcastro/fix/oci-pbs-backup-umask
fix(oci): keep PBS backups working with a private caller umask
2026-11-05 18:05:52 +01:00
MacRimi 2ef15f94a5 fix(oci): read device lines Proxmox writes without path= 2026-11-05 17:21:37 +01:00
github-actions[bot] 8644d5021e Update AppImage beta build (2026-11-04 21:45:44) 2026-11-04 21:45:44 +00:00
Jonatan Castro 7605a6a2fa fix(oci): preserve recorded container device GIDs 2026-11-04 21:24:04 +01:00
Jonatan Castro 007cf78ec2 Authorize USB/IP imports and retain USB references through hotplug cleanup 2026-11-04 11:05:19 +01:00
Jonatan Castro 0ca936f3b4 fix(oci): stage private images for the mapped Proxmox reader 2026-11-04 03:25:39 +01:00
Jonatan Castro bf27c3e1da fix(oci): isolate retained PBS backup creation permissions 2026-11-04 03:09:47 +01:00
Jonatan Castro 150d15950d fix(backup): isolate native Borg from AppImage Python and library paths 2026-11-04 02:52:29 +01:00
Jonatan Castro fd061054d4 fix(monitor): avoid foreign Borg binary in ARM builds and smoke-test launcher 2026-11-04 02:52:05 +01:00
Jonatan Castro d3005bbaff Wait for disposable SSH account processes before fixture cleanup 2026-11-04 02:48:31 +01:00
Jonatan Castro e361195abc Fail closed when the kernel retains an unplugged USB/IP match 2026-11-04 02:48:06 +01:00
Jonatan Castro 5d2c95a22b Exercise restricted SSH with an accessible isolated account home 2026-11-04 02:47:01 +01:00
Jonatan Castro 76696a5271 fix(monitor): preserve an OVS VLAN tag of zero 2026-11-04 02:46:58 +01:00
Jonatan Castro 2bcc0c1e61 feat(monitor): validate native ARM64 and AMD64 AppImage builds 2026-11-04 02:46:13 +01:00
Jonatan Castro 2fd55f8c88 feat(monitor): discover Open vSwitch network topology 2026-11-04 02:45:28 +01:00
Jonatan Castro 3c09800ab5 Restrict USB/IP SSH access and clean stale device matches 2026-11-04 02:45:19 +01:00
Jonatan Castro 84d860f71b test(ai): run availability regressions on provider catalog changes 2026-11-04 02:42:43 +01:00
Jonatan Castro 344346ff9d fix(ai): refresh Groq recommendations and remove unavailable OpenRouter models 2026-11-04 02:41:58 +01:00
Jonatan Castro 0a4824e2f1 Preserve installed Nextcloud cache settings and configuration ownership 2026-11-04 00:40:52 +01:00
Jonatan Castro a97dafacf6 Fix local HTTPS readiness for native OCI stacks 2026-11-04 00:17:35 +01:00
Jonatan Castro eef425666a Fix native Nextcloud bootstrap quoting and initial configuration seeding 2026-11-04 00:04:57 +01:00
Jonatan Castro f4e7f7d525 Keep the MariaDB catalog description aligned with the recipe 2026-11-03 23:40:23 +01:00
Jonatan Castro 0d4c6e6bd3 Merge remote-tracking branch 'origin/develop' into feature/oci-nextcloud-mariadb 2026-11-03 23:39:23 +01:00
Jonatan Castro 3ff9885394 Add LinuxServer Nextcloud stack with MariaDB and Redis 2026-11-03 23:39:23 +01:00
MacRimi bec4606724 Wait for the attached USB/IP port and release USB/IP sharing on uninstall 2026-10-18 17:58:05 +02:00
MacRimi f3be456062 Merge remote-tracking branch 'origin/develop' into HEAD 2026-10-18 17:52:01 +02:00
Jonatan b4c9ffb54b Complete translations for all supported languages 2026-10-16 23:00:17 +02:00
Jonatan 548e237096 Keep SSH fixture keys under a StrictModes-safe directory 2026-10-16 22:07:05 +02:00
Jonatan 440c3df16f Add selected USB/IP device sharing over SSH 2026-10-16 22:04:36 +02:00
78 changed files with 7522 additions and 77 deletions
+33
View File
@@ -0,0 +1,33 @@
"""Reject foreign ELF files in a native AppDir without executing any binary."""
import argparse
from pathlib import Path
import struct
def check(directory, architecture):
expected = {'x86_64': 62, 'aarch64': 183}[architecture]
checked = 0
for path in Path(directory).rglob('*'):
if path.is_symlink() or not path.is_file():
continue
with path.open('rb') as stream:
header = stream.read(20)
if not header.startswith(b'\x7fELF'):
continue
if len(header) != 20 or header[5] not in (1, 2):
raise ValueError(f'Invalid ELF header: {path}')
machine = struct.unpack('<H' if header[5] == 1 else '>H', header[18:20])[0]
if machine != expected:
raise ValueError(f'Foreign ELF machine {machine} in {architecture} AppDir: {path}')
checked += 1
if not checked:
raise ValueError('No native ELF files found in AppDir')
return checked
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('directory', type=Path)
parser.add_argument('architecture', choices=('x86_64', 'aarch64'))
args = parser.parse_args()
print(f'PASS: {check(args.directory, args.architecture)} native ELF files')
+301
View File
@@ -0,0 +1,301 @@
"""Disposable QEMU-only real-kernel USB/IP regression lab. Never use on a host."""
import contextlib, importlib.util, os, select, socket, subprocess, sys, tempfile, termios, time, tty
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts/devices"))
import usbip_manager as m
from usbip_protocol import Gate
def run(*args):
result = subprocess.run(args, text=True, capture_output=True)
assert result.returncode == 0, (args, result.stdout, result.stderr)
return result.stdout
def put(p, v):
Path(p).write_text(v)
def wait(test):
for _ in range(100):
value = test()
if value:
return value
time.sleep(0.05)
raise AssertionError("condition timed out")
def gadget(name, serial, udc):
g = Path("/sys/kernel/config/usb_gadget") / name
g.mkdir()
for f, v in [("idVendor", "0x1d6b"), ("idProduct", "0x0104")]:
put(g / f, v)
(g / "strings/0x409").mkdir()
for f, v in [
("serialnumber", serial),
("manufacturer", "Regression lab"),
("product", name),
]:
put(g / "strings/0x409" / f, v)
(g / "configs/c.1").mkdir()
(g / "functions/acm.usb0").mkdir()
(g / "configs/c.1/acm.usb0").symlink_to(g / "functions/acm.usb0")
put(g / "UDC", udc)
return g
def find(serial):
return next((d for d in m.inventory() if d["serial"] == serial), None)
def chosen(serial, id):
return dict(wait(lambda: find(serial)), id=id)
def control(port, busid):
import struct
with socket.create_connection(("127.0.0.1", port)) as s:
s.sendall(
struct.pack("!HHI", 0x111, 0x8003, 0) + busid.encode().ljust(32, b"\0")
)
reply = s.recv(8)
assert len(reply) == 8
return struct.unpack("!HHI", reply)[2]
def main():
assert sys.argv[1:] == [
"--disposable-vm"
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
assert (
os.geteuid() == 0 and "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
)
assert not Path("/sys/kernel/config/usb_gadget").exists() or not list(
Path("/sys/kernel/config/usb_gadget").iterdir()
), "Start with a fresh disposable VM"
run("modprobe", "dummy_hcd", "num=3")
run("modprobe", "libcomposite")
run("modprobe", "usbip-host")
run("modprobe", "vhci-hcd")
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
assert len(udcs) == 3
a_udc = udcs[0]
a = gadget("selected-a", "original-a", udcs[0])
b = gadget("selected-b", "original-b", udcs[1])
time.sleep(0.5)
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
m.CONFIG_DIR = root / "config"
m.SERVER_RUN_DIR = root / "run"
m.RUN_DIR = root / "client"
selected_a = chosen("original-a", "abcdef123456")
selected_b = chosen("original-b", "fedcba654321")
config = {"server": True, "exports": [selected_a, selected_b], "imports": []}
m.atomic_json(m.CONFIG_DIR / "config.json", config)
owned = {}
leases = {}
daemon = subprocess.Popen(
["usbipd", "-4"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
gate = None
proxy = None
try:
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
def claim(name):
for selected in config["exports"]:
id = selected["id"]
lease = leases.get(id)
record = owned.get(id)
if lease and lease.ready and record and record["busid"] == name:
return dict(record, id=id)
gate = Gate(root / "gate.sock", 0, claim)
proxy = subprocess.Popen(
[
"socat",
"TCP-LISTEN:33240,bind=127.0.0.1,reuseaddr,fork",
"UNIX-CONNECT:" + str(root / "gate.sock"),
]
)
time.sleep(0.2)
# A native independent client remains attached throughout A's physical cycle.
print(
run(
"usbip",
"--tcp-port",
"33240",
"attach",
"-r",
"127.0.0.1",
"-b",
selected_b["busid"],
),
flush=True,
)
def serial_transfer(gadget=b, serial="original-b"):
# Gadget B's function tty and remote vhci serial port, not local A's tty.
gadget_tty = (
"/dev/ttyGS"
+ (gadget / "functions/acm.usb0/port_num").read_text().strip()
)
client = wait(
lambda: next(
(
p
for p in Path("/sys/class/tty").glob("ttyACM*")
if "vhci_hcd" in str(p.resolve())
and any(
(q / "serial").exists()
and (q / "serial").read_text().strip() == serial
for q in p.resolve().parents
)
),
None,
)
)
with contextlib.ExitStack() as stack:
fds = [
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
for path in (gadget_tty, "/dev/" + client.name)
]
for fd in fds:
stack.callback(os.close, fd)
tty.setraw(fd)
termios.tcflush(fd, termios.TCIOFLUSH)
payload = b"independent-b-still-transferring\n"
os.write(fds[0], payload)
assert select.select([fds[1]], [], [], 3)[0]
assert os.read(fds[1], len(payload)) == payload
serial_transfer()
for iteration in range(20):
put(a / "UDC", "\n")
time.sleep(0.03)
m.reconcile_exports(config, owned, leases)
assert selected_a["busid"] not in m.MATCH_BUSID.read_text().split()
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (
errors := m.reconcile_exports(config, owned, leases)
), errors
assert leases[selected_a["id"]].ready
serial_transfer()
print(
"PASS: 20 unplug/replug cycles; stale slot removed and original exported; independent native serial transfer survives",
flush=True,
)
put(a / "UDC", "\n")
time.sleep(0.05)
m.reconcile_exports(config, owned, leases)
a_udc = udcs[2]
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
assert owned[selected_a["id"]]["busid"] != selected_a["busid"]
serial_transfer()
print(
"PASS: moving original to another USB port preserves independent serial transfer",
flush=True,
)
# An unselected replacement returns to a local driver and cannot import.
put(a / "UDC", "\n")
time.sleep(0.05)
put(a / "strings/0x409/serialnumber", "unselected-replacement")
put(a / "UDC", a_udc)
replacement = wait(lambda: find("unselected-replacement"))
errors = m.reconcile_exports(config, owned, leases)
assert selected_a["id"] in errors
assert find("unselected-replacement")["driver"] == "usb"
assert control(33240, replacement["busid"]) != 0
# Also bind that unselected replacement in a separate native setup: the
# daemon can see it, but a direct import through our gate must still fail.
run("usbip", "bind", "-b", replacement["busid"])
assert replacement["busid"] in run("usbip", "list", "-r", "127.0.0.1")
assert control(33240, replacement["busid"]) != 0
m.reconcile_exports(config, owned, leases)
assert find("unselected-replacement")["driver"] == "usbip-host"
run("usbip", "unbind", "-b", replacement["busid"])
serial_transfer()
print(
"PASS: same-port replacement stays local, native import rejected, independent serial survives",
flush=True,
)
# Same original then can be selected, imported and exchange native URBs.
put(a / "UDC", "\n")
time.sleep(0.1)
put(a / "strings/0x409/serialnumber", "original-a")
put(a / "UDC", a_udc)
wait(lambda: find("original-a"))
time.sleep(0.25)
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
print(
run(
"usbip",
"--tcp-port",
"33240",
"attach",
"-r",
"127.0.0.1",
"-b",
find("original-a")["busid"],
),
flush=True,
)
wait(lambda: len(m.imported_ports()) == 2)
serial_transfer(a, "original-a")
serial_transfer()
for port in m.imported_ports():
if port["busid"] == find("original-a")["busid"]:
run("usbip", "detach", "-p", str(port["port"]))
# Simulate losing process-owned descriptors before a present-device
# release. The manager must acquire and verify fresh usbfs pins.
leases.pop(selected_a["id"]).close()
config["exports"] = [selected_b]
assert not (errors := m.reconcile_exports(config, owned, leases)), errors
assert find("original-a")["driver"] == "usb"
serial_transfer()
print(
"PASS: original reimport and present-device release restore local driver",
flush=True,
)
finally:
if proxy:
proxy.terminate()
proxy.wait()
if gate:
gate.terminate()
for port in m.imported_ports():
with contextlib.suppress(Exception):
run("usbip", "detach", "-p", str(port["port"]))
config["exports"] = []
print("cleanup", m.reconcile_exports(config, owned, leases), flush=True)
daemon.terminate()
daemon.wait()
for lease in leases.values():
lease.close()
print("PASS: no Oops/BUG in kernel log", flush=True)
for g in (a, b):
if (g / "UDC").read_text().strip():
put(g / "UDC", "\n")
(g / "configs/c.1/acm.usb0").unlink()
(g / "functions/acm.usb0").rmdir()
(g / "configs/c.1").rmdir()
(g / "strings/0x409").rmdir()
g.rmdir()
assert not any(
x in run("dmesg") for x in ["BUG:", "Oops:", "general protection fault"]
)
if __name__ == "__main__":
main()
+153
View File
@@ -0,0 +1,153 @@
"""Opt-in fresh-QEMU lab: actual generated units, restricted SSH and native USB URBs."""
import contextlib, os, select, shutil, sys, termios, time, tty
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "scripts/devices"))
from check_usbip_kernel import m, run, put, gadget, find
def wait(test, seconds=30):
end = time.monotonic() + seconds
while time.monotonic() < end:
value = test()
if value:
return value
time.sleep(0.1)
raise AssertionError("timeout")
def transfer(g, serial):
client = wait(
lambda: next(
(
p
for p in Path("/sys/class/tty").glob("ttyACM*")
if "vhci_hcd" in str(p.resolve())
and any(
(q / "serial").exists()
and (q / "serial").read_text().strip() == serial
for q in p.resolve().parents
)
),
None,
)
)
with contextlib.ExitStack() as stack:
fds = [
os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
for path in (
"/dev/ttyGS" + (g / "functions/acm.usb0/port_num").read_text().strip(),
"/dev/" + client.name,
)
]
for fd in fds:
stack.callback(os.close, fd)
tty.setraw(fd)
termios.tcflush(fd, termios.TCIOFLUSH)
for src, dst in ((fds[0], fds[1]), (fds[1], fds[0])):
payload = b"actual-systemd-ssh-usbip-serial\n"
os.write(src, payload)
assert select.select([dst], [], [], 5)[0]
assert os.read(dst, len(payload)) == payload
def main():
assert sys.argv[1:] == [
"--disposable-vm"
], "Requires --disposable-vm on a fresh throwaway QEMU VM"
assert not Path("/etc/proxmenux/usbip").exists()
assert "QEMU" in Path("/sys/class/dmi/id/sys_vendor").read_text()
for name in ("usbip_manager.py", "usbip_protocol.py", "usbip_lifetime.py"):
target = Path("/usr/local/share/proxmenux/scripts/devices") / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(m.SCRIPT.parent / name, target)
run("modprobe", "dummy_hcd", "num=3")
run("modprobe", "libcomposite")
run("modprobe", "usbip-host")
run("modprobe", "vhci-hcd")
udcs = sorted(p.name for p in Path("/sys/class/udc").iterdir())
assert len(udcs) == 3
assert not list(Path("/sys/kernel/config/usb_gadget").iterdir())
a = gadget("service-a", "service-a", udcs[0])
b = gadget("service-b", "service-b", udcs[1])
time.sleep(0.5)
key = Path("/root/service-usbip-key")
run("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key))
m.authorize_key(str(key) + ".pub")
public = Path("/etc/ssh/ssh_host_ed25519_key.pub").read_text().split()
Path("/root/.ssh/known_hosts").write_text(
"127.0.0.1 " + public[0] + " " + public[1] + "\n"
)
imports = []
try:
m.update_server(True)
sa = m.add_export(wait(lambda: find("service-a"))["busid"])
sb = m.add_export(wait(lambda: find("service-b"))["busid"])
wait(lambda: len([e for e in m.catalog() if e["status"] == "exported"]) == 2)
remote = m.validate_remote("127.0.0.1", 22, str(key))
for selected, port in ((sa, 33240), (sb, 33241)):
imports.append(m.add_import(remote, selected["id"], port))
wait(lambda: len(m.imported_ports()) == 2)
transfer(a, "service-a")
transfer(b, "service-b")
print(
"PASS: actual generated server/client systemd units and restricted SSH carry bidirectional native serial on two USBs",
flush=True,
)
for cycle in range(3):
put(a / "UDC", "\n")
time.sleep(0.2)
transfer(b, "service-b")
put(a / "UDC", udcs[0])
wait(lambda: find("service-a"))
time.sleep(0.5)
# An old vhci port may still exist; wait for the new guest driver and verify data.
wait(
lambda: next(
(
e
for e in m.catalog()
if e["id"] == sa["id"] and e["status"] == "exported"
),
None,
)
)
time.sleep(6)
transfer(a, "service-a")
transfer(b, "service-b")
print(
"PASS: three physical reconnects automatically reattach through restricted SSH; second serial connection survives",
flush=True,
)
m.remove_export(sa["id"])
assert find("service-a")["driver"] == "usb"
transfer(b, "service-b")
print(
"PASS: stop-sharing waits for real release and returns local driver while independent client keeps transferring",
flush=True,
)
for item in imports:
m.remove_import(item["id"])
imports = []
m.update_server(False)
assert not m.read_json(m.state_path(), {})
assert find("service-b")["driver"] == "usb"
print(
"PASS: disable stops listeners, removes owned bindings, leaves both local USB drivers restored",
flush=True,
)
finally:
for item in imports:
with contextlib.suppress(Exception):
m.remove_import(item["id"])
with contextlib.suppress(Exception):
m.update_server(False)
print(
run("journalctl", "-u", m.SERVER_UNIT, "--no-pager", "-n", "20"), flush=True
)
if __name__ == "__main__":
main()
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Linux-only integration: real namespaces, socat and SSH; no USB hardware.
Run as root in the disposable CI runner. All keys, configuration, listeners and
child processes belong to a temporary fixture; no systemd units are installed.
"""
import importlib.util
import os
from pathlib import Path
import pwd
import signal
import socket
import stat
import subprocess
import sys
import tempfile
import time
SOURCE = Path(__file__).resolve().parents[2] / "scripts/devices/usbip_manager.py"
def port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def wait_until(test, processes, log, timeout=15):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
for process in processes:
if process.poll() is not None:
raise AssertionError(f"Fixture exited ({process.returncode}): {log.read_text()}")
try:
if test():
return
except (ConnectionError, FileNotFoundError, socket.timeout):
pass
time.sleep(0.1)
raise AssertionError(f"Fixture did not become ready: {log.read_text()}")
def echo(sock):
# Exercise the production protocol gate through the real SSH stream. Empty
# fixture selection must produce an empty native DEVLIST, never raw echo.
import struct
sock.settimeout(10)
sock.sendall(struct.pack("!HHI", 0x0111, 0x8005, 0))
received = bytearray()
while len(received) < 12:
chunk = sock.recv(12 - len(received))
if not chunk:
break
received.extend(chunk)
assert bytes(received) == struct.pack("!HHII", 0x0111, 5, 0, 0), received
def main():
assert sys.platform == "linux" and os.geteuid() == 0, "Use the disposable Linux CI runner as root"
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
manager = importlib.util.module_from_spec(spec)
spec.loader.exec_module(manager)
# A mistaken direct launch must never create a listener in this namespace.
try:
manager.require_private_network()
except manager.USBIPError:
pass
else:
raise AssertionError("Expected the host namespace to be rejected")
processes = []
tunnel = None
username = f"pmx-usbip-test-{os.getpid()}"
subprocess.run(["useradd", "--system", "--user-group", "--home-dir", "/nonexistent", "--shell", "/bin/sh", "--password", "*", username], check=True)
account = pwd.getpwnam(username)
manager.TRANSPORT_USER = username
# StrictModes checks every ancestor of AuthorizedKeysFile. A private
# subdirectory of /tmp still fails because /tmp itself is world writable.
# Keep the disposable fixture below root's home without changing its SSH
# configuration, account settings or existing authorized_keys.
with tempfile.TemporaryDirectory(prefix="pmx-usbip-", dir="/var/lib") as directory:
root = Path(directory)
os.chown(root, 0, account.pw_gid)
root.chmod(0o750)
fixture_source = root / "usbip_manager.py"
fixture_source.write_bytes(SOURCE.read_bytes())
fixture_source.chmod(0o644)
for name in ("usbip_protocol.py", "usbip_lifetime.py"):
(root / name).write_bytes((SOURCE.parent / name).read_bytes())
subprocess.run(["usermod", "--home", str(root), username], check=True)
log = root / "transport.log"
with log.open("w") as log_handle:
try:
fake_bin = root / "bin"
fake_bin.mkdir()
# The real daemon requires usbip-host and physical devices.
# Echo stands in only for its TCP endpoint, inside the namespace.
fake_daemon = fake_bin / "usbipd"
fake_daemon.write_text("""#!/usr/bin/python3
import socketserver
class Echo(socketserver.BaseRequestHandler):
def handle(self):
import struct
data = b''
while len(data) < 8:
block = self.request.recv(8 - len(data))
if not block:
return
data += block
if data == struct.pack('!HHI', 0x0111, 0x8005, 0):
self.request.sendall(struct.pack('!HHII', 0x0111, 5, 0, 0))
socketserver.ThreadingTCPServer.allow_reuse_address = True
socketserver.ThreadingTCPServer(('0.0.0.0', 3240), Echo).serve_forever()
""")
fake_daemon.chmod(0o755)
config = root / "config"
manager.atomic_json(config / "config.json", {"server": True, "exports": [], "imports": []})
server_run = root / "server"
runner = root / "server.py"
runner.write_text(f"""import importlib.util
from pathlib import Path
import subprocess
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.CONFIG_DIR = Path({str(config)!r})
m.SERVER_RUN_DIR = Path({str(server_run)!r})
m.SYSFS = Path({str(root / 'no-usb-devices')!r})
m.TRANSPORT_USER = {username!r}
m.TRANSPORT_HOME = Path({str(root)!r})
subprocess.run(['ip', 'link', 'set', 'lo', 'up'], check=True)
m.serve()
""")
server = subprocess.Popen(["unshare", "--net", sys.executable, str(runner)],
env={**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"},
stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(server)
unix_path = server_run / "usbip.sock"
wait_until(lambda: unix_path.exists(), [server], log)
assert os.readlink(f"/proc/{server.pid}/ns/net") != os.readlink("/proc/self/ns/net")
assert stat.S_IMODE(server_run.stat().st_mode) == 0o750
assert stat.S_IMODE(unix_path.stat().st_mode) == 0o660
# Linux /proc/net reflects the caller's network namespace.
listening = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", ":3240"], text=True)
assert not listening.strip(), "Raw USB/IP TCP leaked into the host network namespace"
# Wait for both the proxy and its private TCP peer.
def unix_ready():
with socket.socket(socket.AF_UNIX) as connection:
connection.connect(str(unix_path))
echo(connection)
return True
wait_until(unix_ready, [server], log)
for name in ("host", "client", "wrong-client"):
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(root / name)], check=True)
ssh_dir = root / ".ssh"
ssh_dir.mkdir(mode=0o750)
os.chown(ssh_dir, 0, account.pw_gid)
authorized = ssh_dir / "authorized_keys"
gate = root / "gate.py"
gate.write_text(f"""import importlib.util
from pathlib import Path
spec = importlib.util.spec_from_file_location('usbip_manager', {str(fixture_source)!r})
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
m.TRANSPORT_USER = {username!r}
m.TRANSPORT_HOME = Path({str(root)!r})
m.SERVER_RUN_DIR = Path({str(server_run)!r})
m.REMOTE_SOCKET = {str(unix_path)!r}
m.transport()
""")
gate.chmod(0o644)
authorized.write_text(f'restrict,command="{sys.executable} {gate}" ' + (root / "client.pub").read_text())
os.chown(authorized, 0, account.pw_gid)
authorized.chmod(0o640)
known = root / "known_hosts"
known.write_text("")
known.chmod(0o600)
ssh_port, local_port = port(), port()
ssh_config = root / "sshd_config"
ssh_config.write_text(f"""Port {ssh_port}
ListenAddress 127.0.0.1
HostKey {root / 'host'}
PidFile {root / 'sshd.pid'}
AuthorizedKeysFile {authorized}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
StrictModes yes
AllowUsers {username}
AllowTcpForwarding local
AllowStreamLocalForwarding local
LogLevel VERBOSE
""")
Path("/run/sshd").mkdir(exist_ok=True, mode=0o755)
sshd = subprocess.Popen(["/usr/sbin/sshd", "-D", "-e", "-f", str(ssh_config)],
stdout=log_handle, stderr=log_handle, start_new_session=True)
processes.append(sshd)
def ssh_ready():
with socket.create_connection(("127.0.0.1", ssh_port), timeout=1):
return True
wait_until(ssh_ready, [server, sshd], log)
remote = manager.validate_remote("127.0.0.1", ssh_port, str(root / "client"))
extra = ["-o", f"UserKnownHostsFile={known}", "-o", "GlobalKnownHostsFile=/dev/null"]
def check_ssh(selected):
args = manager.ssh_args(selected)
args[1:1] = extra
return subprocess.run(args + ["--", selected["host"], "catalog"], capture_output=True, text=True, timeout=15)
unknown = check_ssh(remote)
assert unknown.returncode != 0 and "Host key verification failed" in unknown.stderr, unknown.stderr
host_public = (root / "host.pub").read_text().split()
known.write_text(f"[127.0.0.1]:{ssh_port} {host_public[0]} {host_public[1]}\n")
wrong = check_ssh(remote | {"key": str(root / "wrong-client")})
assert wrong.returncode != 0 and "Permission denied" in wrong.stderr, wrong.stderr
accepted = check_ssh(remote)
assert accepted.returncode == 0, accepted.stderr + "\n" + log.read_text()
forbidden = manager.ssh_args(remote)
forbidden[1:1] = extra
denied = subprocess.run(forbidden + ["--", remote["host"], "id"], capture_output=True, text=True, timeout=15)
assert denied.returncode != 0, "Forced command allowed a shell command"
forwarded = subprocess.run(forbidden + ["-W", "127.0.0.1:22", "--", remote["host"]], capture_output=True, text=True, timeout=15)
assert forwarded.returncode != 0 and "prohibited" in forwarded.stderr, forwarded.stderr
original = manager.ssh_args
def fixture_ssh(selected):
args = original(selected)
args[1:1] = extra
return args
manager.ssh_args = fixture_ssh
tunnel = manager.LoopbackTunnel(remote | {"local_port": local_port})
with socket.socket() as attacker:
try:
attacker.bind(("127.0.0.1", local_port))
except OSError:
pass
else:
raise AssertionError("Another process stole the tunnel port")
def tunnel_ready():
with socket.create_connection(("127.0.0.1", local_port), timeout=1) as connection:
echo(connection)
return True
wait_until(tunnel_ready, processes, log)
listener = subprocess.check_output(["ss", "-H", "-ltn", "sport", "=", f":{local_port}"], text=True)
assert f"127.0.0.1:{local_port}" in listener and f"0.0.0.0:{local_port}" not in listener, listener
print("PASS: private namespace, dedicated-group Unix socket, no host USB/IP listener")
print("PASS: unknown/wrong keys, shell commands and arbitrary forwarding rejected; dedicated key accepted")
print("PASS: native USB/IP DEVLIST across real SSH -> Unix protocol gate -> private TCP")
finally:
if tunnel:
tunnel.terminate()
for process in reversed(processes):
if process.poll() is None:
os.killpg(process.pid, signal.SIGTERM)
for process in reversed(processes):
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
process.wait(timeout=5)
subprocess.run(["pkill", "-KILL", "-u", str(account.pw_uid)], check=False)
# PAM/sshd children need to exit and be reaped before userdel.
for attempt in range(50):
cleanup = subprocess.run(["userdel", username], capture_output=True, text=True)
if cleanup.returncode == 0:
break
if cleanup.returncode != 8:
raise AssertionError(cleanup.stderr)
time.sleep(0.1)
else:
subprocess.run(["ps", "-fu", username], check=False)
raise AssertionError(cleanup.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Exercise the extracted launcher and its real native Python environment,
# intercepting only the final server exec so no host-monitoring server starts.
set -eu
appdir="$1"
export PMX_SMOKE_PYTHON
PMX_SMOKE_PYTHON="$(command -v python)"
fixture_bin="$(mktemp -d)"
trap 'rm -rf "$fixture_bin"' EXIT
cat > "$fixture_bin/python3" <<'WRAPPER'
#!/bin/sh
case "$1" in
*/flask_server.py)
exec "$PMX_SMOKE_PYTHON" -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print("PASS: extracted AppRun native runtime")'
;;
*) exec "$PMX_SMOKE_PYTHON" "$@" ;;
esac
WRAPPER
chmod +x "$fixture_bin/python3"
PATH="$fixture_bin:$PATH" bash "$appdir/AppRun"
+2 -1
View File
@@ -202,7 +202,8 @@ class InputAndCancellationTests(StorageFixture):
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
ui=Mock();ui.choose.side_effect=['custom',None]
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(cli,'_catalog_options',return_value=([],{}, {}, {}, [])), \
patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
patch.object(updates,'UpdateCheck') as checker, \
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
# Keep this wording/error-handler test offline and free of writes.
+488
View File
@@ -0,0 +1,488 @@
"""USB/IP ownership, device identity, SSH policy and service regression tests.
No physical USB devices, live systemd units or host configuration are changed.
The optional Linux integration lane exercises actual SSH, socat and namespaces.
"""
import copy
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
SOURCE = ROOT / "scripts/devices/usbip_manager.py"
EXPORT = "abcdef123456"
CLIENT = "fedcba654321"
def load():
spec = importlib.util.spec_from_file_location("usbip_manager", SOURCE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class USBIPTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.m = load()
for attr, path in (("CONFIG_DIR", "config"), ("RUN_DIR", "client-run"),
("SERVER_RUN_DIR", "server-run"), ("UNIT_DIR", "units"), ("SYSFS", "sysfs"),
("NET_SYSFS", "net")):
setattr(self.m, attr, self.root / path)
getattr(self.m, attr).mkdir()
class MatchTable:
# Mirror the kernel contract, not regular-file write semantics:
# del(ALLOC)=REMOV, del(ADDED/REMOV) retains the named slot.
def __init__(self):
self.slots = {}
def write_text(self, value):
action, busid = value.split()
if self.slots.get(busid) == "ALLOC":
self.slots[busid] = "REMOV"
def read_text(self):
return " ".join(self.slots)
self.m.MATCH_BUSID = MatchTable()
self.account = SimpleNamespace(pw_uid=1234, pw_gid=1234)
self.calls = []
self.key = self.root / "id_ed25519"
self.key.write_text("test-key")
self.key.chmod(0o600)
self.remote = {"host": "usb.example.test", "ssh_port": 2222, "key": str(self.key)}
def config(self, exports=None, imports=None, server=True):
value = {"server": server, "exports": exports or [], "imports": imports or []}
self.m.atomic_json(self.m.CONFIG_DIR / "config.json", value)
return value
def test_a_usb_network_adapter_is_not_offered(self):
self.device("1-3", serial="nic-1")
(self.m.NET_SYSFS / "enx001122").mkdir()
(self.m.NET_SYSFS / "enx001122" / "device").symlink_to(self.m.SYSFS / "1-3" / "1-3:1.0")
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["blocked"],
"USB network adapters cannot be shared")
self.device("1-4", serial="zigbee-9")
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-4")["blocked"], "")
def device(self, busid="1-2", serial="zigbee-123", device_class="00", interface="03", driver="usb"):
path = self.m.SYSFS / busid
path.mkdir()
values = {"idVendor": "10c4", "idProduct": "ea60", "serial": serial,
"product": "Test Zigbee adapter", "bDeviceClass": device_class}
for key, value in values.items():
(path / key).write_text(value)
child = path / f"{busid}:1.0"
child.mkdir()
(child / "bInterfaceClass").write_text(interface)
self.set_driver(busid, driver)
return next(d for d in self.m.inventory() if d["busid"] == busid)
def set_driver(self, busid, driver):
target = self.root / "drivers" / driver
target.mkdir(parents=True, exist_ok=True)
path = self.m.SYSFS / busid / "driver"
path.unlink(missing_ok=True)
path.symlink_to(target, target_is_directory=True)
def selected(self, device):
return {k: device[k] for k in ("busid", "vendor", "product", "serial", "name")} | {"id": EXPORT}
def usb(self, args, timeout=20):
self.calls.append(args)
if args[:2] == ["usbip", "bind"]:
self.set_driver(args[-1], "usbip-host")
self.m.MATCH_BUSID.slots[args[-1]] = "ALLOC"
elif args[:2] == ["usbip", "unbind"]:
self.set_driver(args[-1], "usb")
self.m.MATCH_BUSID.slots.pop(args[-1], None)
return ""
def test_storage_composite_interfaces_and_hubs_are_never_exported(self):
for index, (base, interface) in enumerate((("08", "06"), ("09", "00"), ("00", "08"), ("", "03"))):
device = self.device(busid=f"1-{index + 1}", device_class=base, interface=interface)
self.assertTrue(device["blocked"])
self.config()
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
self.m.add_export(device["busid"])
command.assert_not_called()
self.assertEqual(self.m.load_config()["exports"], [])
def test_export_intent_is_private_and_bind_is_idempotent(self):
device = self.device()
self.config()
selected = self.m.add_export(device["busid"])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
self.assertEqual(self.m.reconcile_exports(self.m.load_config(), owned), {})
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertEqual(self.m.catalog()[0]["id"], selected["id"])
self.assertEqual(self.m.catalog()[0]["status"], "exported")
for path in (self.m.CONFIG_DIR / "config.json", self.m.state_path()):
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertNotEqual(self.m.RUN_DIR, self.m.SERVER_RUN_DIR)
def test_port_move_fails_closed_when_kernel_retains_old_slot(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(config, owned)
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(serial="other-device", driver="usbip-host")
self.device(busid="2-4")
errors = self.m.reconcile_exports(config, owned)
self.assertIn("blocked", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"]])
self.assertIsNone(self.m.catalog()[0]["busid"])
def test_retained_slot_is_rejected_before_detaching_reconnected_local_usb(self):
device = self.device()
selected = self.selected(device)
config = self.config([selected])
self.m.MATCH_BUSID.slots[device["busid"]] = "REMOV"
with patch.object(self.m, "run", side_effect=self.usb):
errors = self.m.reconcile_exports(config, {EXPORT: selected}, {})
self.assertIn("local driver", errors[EXPORT])
self.assertEqual(self.calls, [])
self.assertEqual(self.m.inventory()[0]["driver"], "usb")
def test_imported_usb_does_not_shadow_physical_original(self):
device = self.device()
imported = self.m.SYSFS / "2-1"
imported.symlink_to(self.m.SYSFS / "1-2", target_is_directory=True)
target = self.root / "vhci_hcd.0" / "2-1"
target.parent.mkdir()
shutil.copytree(self.m.SYSFS / "1-2", target, symlinks=True)
imported.unlink()
imported.symlink_to(target, target_is_directory=True)
devices = self.m.inventory()
self.assertEqual(len(devices), 1)
self.assertEqual(self.m.resolve_device(device, devices)["busid"], "1-2")
def test_no_serial_requires_same_busid_and_ambiguous_serial_is_rejected(self):
selected = self.selected(self.device(serial=""))
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4", serial="")
with self.assertRaises(self.m.USBIPError):
self.m.resolve_device(selected, self.m.inventory())
self.device(busid="1-3", serial="duplicate")
first = self.device(busid="1-4", serial="duplicate")
with self.assertRaises(self.m.USBIPError):
self.m.resolve_device(first, self.m.inventory())
def test_foreign_exports_are_not_adopted_and_disable_only_releases_owned_devices(self):
own = self.selected(self.device())
foreign = self.device(busid="1-3", serial="foreign", driver="usbip-host")
config = self.config([own, self.selected(foreign) | {"id": CLIENT}])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
errors = self.m.reconcile_exports(config, owned)
self.assertIn("outside ProxMenux", errors[CLIENT])
config["server"] = False
self.m.reconcile_exports(config, owned)
self.assertEqual(self.calls, [["usbip", "bind", "--busid", "1-2"], ["usbip", "unbind", "--busid", "1-2"]])
self.assertEqual(owned, {})
self.assertEqual(next(d for d in self.m.inventory() if d["busid"] == "1-3")["driver"], "usbip-host")
def test_recover_bind_intent_and_remove_selected_export(self):
selected = self.selected(self.device(driver="usbip-host"))
self.config([selected])
owned = {EXPORT: selected}
self.m.atomic_json(self.m.state_path(), owned)
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(self.m.load_config(), owned)
self.assertEqual(self.calls, [])
with patch.object(self.m.time, "sleep", side_effect=lambda _: self.m.reconcile_exports(self.m.load_config(), owned)):
self.m.remove_export(EXPORT)
self.m.reconcile_exports(self.m.load_config(), owned)
self.assertEqual(self.calls, [["usbip", "unbind", "--busid", "1-2"]])
def test_failed_bind_is_reported_and_retains_recoverable_intent(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.m.USBIPError("bind failed")):
self.assertEqual(self.m.reconcile_exports(config, owned), {EXPORT: "bind failed"})
self.assertIn(EXPORT, self.m.read_json(self.m.state_path(), {}))
self.assertEqual(self.m.status()["server_status"]["errors"], {EXPORT: "bind failed"})
self.assertEqual(self.m.catalog()[0]["status"], "waiting")
def test_unplugged_kernel_match_is_disarmed_but_not_falsely_forgotten(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {}
with patch.object(self.m, "run", side_effect=self.usb):
self.m.reconcile_exports(config, owned)
shutil.rmtree(self.m.SYSFS / "1-2")
errors = self.m.reconcile_exports(config, owned)
self.assertEqual(owned[EXPORT]["busid"], "1-2")
self.assertIn("blocked", errors[EXPORT])
self.assertEqual(self.m.MATCH_BUSID.slots["1-2"], "REMOV")
self.assertEqual(len(self.calls), 1)
def test_failed_stale_cleanup_does_not_forget_or_replace_ownership(self):
selected = self.selected(self.device())
config = self.config([selected])
owned = {EXPORT: selected}
shutil.rmtree(self.m.SYSFS / "1-2")
self.device(busid="2-4")
with patch.object(self.m, "disarm_match", side_effect=self.m.USBIPError("disarm failed")), patch.object(self.m, "run") as command:
errors = self.m.reconcile_exports(config, owned)
self.assertIn("disarm", errors[EXPORT])
self.assertEqual(owned[EXPORT]["busid"], "1-2")
command.assert_not_called()
def test_authorized_key_is_restricted_and_policy_stays_root_owned(self):
self.m.TRANSPORT_HOME = self.root / "transport-home"
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
public = self.root / "client.pub"
public.write_text("ssh-ed25519 AAAA test-client")
with patch.object(self.m.pwd, "getpwnam", side_effect=[KeyError(), account]), patch.object(
self.m, "run"
) as command, patch.object(self.m.os, "chown"), patch.object(self.m.os, "fchown"):
self.m.authorize_key(public)
command.assert_called_once()
self.assertEqual(command.call_args.args[0][0], "useradd")
policy = self.m.TRANSPORT_HOME / ".ssh/authorized_keys"
self.assertEqual(policy.stat().st_mode & 0o777, 0o640)
self.assertEqual(policy.read_text(), 'restrict,command="/usr/bin/python3 ' + self.m.REMOTE_SCRIPT + ' transport" ssh-ed25519 AAAA\n')
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(
self.m.os, "chown"
), patch.object(self.m.os, "fchown"):
self.m.authorize_key(public)
self.assertEqual(len(policy.read_text().splitlines()), 1)
def test_authorize_never_adopts_an_existing_foreign_account(self):
self.m.TRANSPORT_HOME = self.root / "foreign-home"
account = SimpleNamespace(pw_uid=1234, pw_gid=1234, pw_dir=str(self.m.TRANSPORT_HOME))
public = self.root / "client.pub"
public.write_text("ssh-ed25519 AAAA test-client")
with patch.object(self.m.pwd, "getpwnam", return_value=account), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "existing"):
self.m.authorize_key(public)
command.assert_not_called()
self.assertFalse(self.m.TRANSPORT_HOME.exists())
def test_forced_command_rejects_shell_and_wrong_identity(self):
for command in ("id", "catalog; id", "", "stream --socket /run/other.sock"):
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(
self.m.os, "geteuid", return_value=1234
), patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": command}), patch.object(self.m.os, "execvp") as execute:
with self.assertRaisesRegex(self.m.USBIPError, "Only USB/IP"):
self.m.transport()
execute.assert_not_called()
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=0):
with self.assertRaisesRegex(self.m.USBIPError, "unprivileged"):
self.m.transport()
def test_forced_command_catalog_and_stream_are_fixed(self):
(self.m.SERVER_RUN_DIR / "catalog.json").write_text("[]")
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m.os, "geteuid", return_value=1234):
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "catalog"}), patch("builtins.print") as output:
self.m.transport()
output.assert_called_once_with("[]")
with patch.dict(os.environ, {"SSH_ORIGINAL_COMMAND": "stream"}), patch.object(self.m.os, "execvp") as execute:
self.m.transport()
execute.assert_called_once_with("socat", ["socat", "STDIO", "UNIX-CONNECT:" + self.m.REMOTE_SOCKET])
def test_loopback_port_is_held_until_tunnel_stops(self):
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
port = unused.getsockname()[1]
tunnel = self.m.LoopbackTunnel(self.remote | {"local_port": port})
try:
with socket.socket() as attacker:
with self.assertRaises(OSError):
attacker.bind(("127.0.0.1", port))
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
self.m.LoopbackTunnel(self.remote | {"local_port": port})
finally:
tunnel.terminate()
self.assertEqual(tunnel.poll(), 0)
def test_namespace_guard_fails_before_spawning_a_listener(self):
with patch.object(self.m.os, "readlink", return_value="net:[1]"), patch.object(self.m.subprocess, "Popen") as process:
with self.assertRaisesRegex(self.m.USBIPError, "PrivateNetwork"):
self.m.serve()
process.assert_not_called()
def test_generated_units_are_isolated_quoted_and_owned(self):
self.m.SCRIPT = self.root / 'with space/%and$dollar"quote.py'
with patch("shutil.which", return_value="/usr/sbin/modprobe"):
server = self.m.unit_text("server")
client = self.m.unit_text("client", CLIENT)
self.assertIn("PrivateNetwork=yes", server)
self.assertIn("RuntimeDirectory=proxmenux-usbip-server", server)
self.assertIn("RuntimeDirectoryPreserve=yes", server)
self.assertIn('%%and$$dollar\\"quote.py" serve', server)
self.assertNotIn("PrivateNetwork=yes", client)
self.assertNotIn("RuntimeDirectory=", client)
self.assertIn("vhci-hcd", client)
self.assertIn("Restart=on-failure", client)
unit = self.m.UNIT_DIR / self.m.SERVER_UNIT
unit.write_text("[Unit]\nDescription=Someone else's service\n")
with self.assertRaises(self.m.USBIPError), patch.object(self.m, "run") as command:
self.m.update_server(True)
command.assert_not_called()
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
def test_failed_server_start_restores_configuration_and_removes_new_unit(self):
previous = self.config(server=False)
def fail_start(args, timeout=20):
self.calls.append(args)
if args[:3] == ["systemctl", "enable", "--now"]:
raise self.m.USBIPError("start failed")
return ""
with patch.object(self.m, "transport_account", return_value=self.account), patch.object(self.m, "run", side_effect=fail_start), patch("shutil.which", return_value="/sbin/modprobe"):
with self.assertRaisesRegex(self.m.USBIPError, "start failed"):
self.m.update_server(True)
self.assertEqual(self.m.load_config(), previous)
self.assertFalse((self.m.UNIT_DIR / self.m.SERVER_UNIT).exists())
self.assertIn(["systemctl", "disable", "--now", self.m.SERVER_UNIT], self.calls)
def test_ssh_policy_and_argument_boundaries(self):
remote = self.m.validate_remote(self.remote["host"], 2222, str(self.key))
args = self.m.tunnel_args(remote | {"local_port": 33240})
for required in ("BatchMode=yes", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no",
"PasswordAuthentication=no", "ExitOnForwardFailure=yes"):
self.assertIn(required, args)
self.assertIn("proxmenux-usbip", args)
self.assertNotIn("root", args)
self.assertNotIn("-L", args)
self.assertNotIn("-N", args)
self.assertNotIn("StrictHostKeyChecking=no", args)
self.assertEqual(args[-3:], ["--", "usb.example.test", "stream"])
for host in ("-oProxyCommand=touch /tmp/no", "root@host", "host;id", "host\nother", "$(id)"):
with self.assertRaises(self.m.USBIPError):
self.m.validate_remote(host, 22, str(self.key))
self.key.chmod(0o644)
with self.assertRaisesRegex(self.m.USBIPError, "permissions"):
self.m.validate_remote("192.0.2.1", 22, str(self.key))
def test_ssh_credential_or_catalog_failure_never_saves_a_connection(self):
for failure in (self.m.USBIPError("Permission denied"), self.m.USBIPError("Connection timed out")):
with patch.object(self.m, "run", side_effect=failure):
with self.assertRaises(self.m.USBIPError):
self.m.add_import(self.remote, EXPORT, 33240)
self.assertFalse((self.m.CONFIG_DIR / "config.json").exists())
with patch.object(self.m, "run", return_value='[{"id":"--bad","busid":"1-2"}]'):
with self.assertRaisesRegex(self.m.USBIPError, "valid"):
self.m.remote_catalog(self.remote)
def test_import_service_failure_rolls_back_and_occupied_port_is_rejected(self):
exports = [{"id": EXPORT, "status": "exported", "busid": "1-2"}]
previous = self.config()
with patch.object(self.m, "remote_catalog", return_value=exports):
with socket.socket() as busy:
busy.bind(("127.0.0.1", 0))
with self.assertRaisesRegex(self.m.USBIPError, "in use"):
self.m.add_import(self.remote, EXPORT, busy.getsockname()[1])
with socket.socket() as unused:
unused.bind(("127.0.0.1", 0))
port = unused.getsockname()[1]
with patch("shutil.which", return_value="/sbin/modprobe"), patch.object(self.m, "enable_unit", side_effect=self.m.USBIPError("failed")):
with self.assertRaises(self.m.USBIPError):
self.m.add_import(self.remote, EXPORT, port)
self.assertEqual(self.m.load_config(), previous)
def port(self, **overrides):
return {"port": 2, "status": "Port in Use", "local_busid": "3-1", "host": "127.0.0.1", "tcp_port": 33240,
"busid": "1-2"} | overrides
def test_real_upstream_port_output_and_unowned_port_cleanup(self):
output = """Imported USB devices
====================
Port 02: <Port in Use> at High Speed(480Mbps)
Test Zigbee adapter (10c4:ea60)
3-1 -> usbip://127.0.0.1:33240/1-2
-> remote bus/dev 001/002
Port 03: <Port Error> at Full Speed(12Mbps)
Another adapter
3-2 -> usbip://192.0.2.1:3240/1-3
"""
ports = self.m.parse_ports(output)
self.assertEqual(ports[0], self.port())
self.assertEqual(ports[1]["status"], "Port Error")
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
self.m.atomic_json(owner, ports[0])
reused = self.port(local_busid="3-7")
with patch.object(self.m, "imported_ports", return_value=[reused, ports[1]]), patch.object(self.m, "run") as command:
self.m.release_import(owner)
command.assert_not_called()
self.assertFalse(owner.exists())
self.m.atomic_json(owner, ports[0])
with patch.object(self.m, "imported_ports", return_value=ports), patch.object(self.m, "run") as command:
self.m.release_import(owner)
command.assert_called_once_with(["usbip", "detach", "--port", "2"])
def test_attach_reuses_live_port_and_recovers_error_with_new_remote_busid(self):
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
self.m.atomic_json(owner, self.port())
with patch.object(self.m, "imported_ports", return_value=[self.port()]), patch.object(self.m, "remote_catalog") as remote:
self.assertEqual(self.m.attach_once(selected, owner)["port"], 2)
remote.assert_not_called()
ports = [self.port(status="Port Error")]
def usb(args, timeout=20):
self.calls.append(args)
if "detach" in args:
ports.clear()
elif "attach" in args:
ports.append(self.port(busid="2-4", local_busid="3-5"))
return ""
with patch.object(self.m, "imported_ports", side_effect=lambda: copy.deepcopy(ports)), patch.object(
self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "2-4"}]
), patch.object(self.m, "run", side_effect=usb):
attached = self.m.attach_once(selected, owner)
self.assertEqual(attached["busid"], "2-4")
self.assertEqual(self.calls, [["usbip", "detach", "--port", "2"],
["usbip", "--tcp-port", "33240", "attach", "--remote", "127.0.0.1", "--busid", "2-4"]])
self.assertEqual(self.m.read_json(owner, None), attached)
def test_missing_remote_or_unowned_import_is_never_detached(self):
selected = self.remote | {"id": CLIENT, "local_port": 33240, "export_id": EXPORT}
owner = self.m.RUN_DIR / f"client-{CLIENT}.json"
with patch.object(self.m, "remote_catalog", return_value=[]), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "unplugged"):
self.m.attach_once(selected, owner)
command.assert_not_called()
with patch.object(self.m, "remote_catalog", return_value=[{"id": EXPORT, "status": "exported", "busid": "1-2"}]), patch.object(
self.m, "imported_ports", return_value=[self.port()]
), patch.object(self.m, "run") as command:
with self.assertRaisesRegex(self.m.USBIPError, "unowned"):
self.m.attach_once(selected, owner)
command.assert_not_called()
@unittest.skipUnless(sys.platform == "linux" and shutil.which("systemd-analyze"), "systemd verifier requires Linux")
def test_systemd_accepts_both_generated_service_files(self):
with patch("shutil.which", return_value="/bin/true"):
for name, content in ((self.m.SERVER_UNIT, self.m.unit_text("server")),
(self.m.unit_name(CLIENT), self.m.unit_text("client", CLIENT))):
path = self.m.UNIT_DIR / name
path.write_text(content)
result = subprocess.run(["systemd-analyze", "verify", str(path)], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_menu_and_search_expose_server_and_client_actions(self):
menu = (ROOT / "scripts/menus/hw_grafics_menu.sh").read_text()
self.assertIn('bash "$LOCAL_SCRIPTS/devices/usbip_menu.sh"', menu)
index = json.loads((ROOT / "scripts/menus/search_index.json").read_text())
self.assertTrue(any(i["script"] == "devices/usbip_menu.sh" and "usbip" in i["keywords"] for i in index))
for script in (ROOT / "scripts/devices/usbip_menu.sh", ROOT / "scripts/menus/hw_grafics_menu.sh"):
subprocess.run(["bash", "-n", str(script)], check=True)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,112 @@
"""Absent-slot recovery must require a verified pin and completed kernel removal."""
import importlib.util
import errno
from pathlib import Path
import socket
import tempfile
import unittest
from unittest.mock import Mock, patch
SOURCE = Path(__file__).resolve().parents[3] / "scripts/devices/usbip_lifetime.py"
spec = importlib.util.spec_from_file_location("usbip_lifetime", SOURCE)
l = importlib.util.module_from_spec(spec)
spec.loader.exec_module(l)
class LifetimeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
lease = l.Lease.__new__(l.Lease)
lease.path = self.root / "1-2"
lease.devpath = "/devices/test/usb1/1-2"
lease.bus = 1
lease.number = 3
lease.sequence = 100
lease.ready = True
lease.removed = False
lease.fds = []
lease.events = Mock()
lease.events.recvmsg.side_effect = BlockingIOError()
self.lease = lease
self.match = Mock()
self.match.parent = self.root
self.match.read_text.side_effect = lambda: (
"" if (self.root / "rebind").exists() else "1-2"
)
def event(self, **changes):
values = {
"ACTION": "remove",
"DEVTYPE": "usb_device",
"DEVPATH": self.lease.devpath,
"BUSNUM": "001",
"DEVNUM": "003",
"SEQNUM": "101",
} | changes
return b"\0".join(f"{k}={v}".encode() for k, v in values.items()) + b"\0"
def test_no_rebind_without_a_verified_lifetime_pin(self):
self.lease.ready = False
self.lease.removed = True
with self.assertRaises(l.LifetimeError):
self.lease.release(self.match, Mock())
self.assertFalse((self.root / "rebind").exists())
def test_rebind_requires_completed_removal_not_only_missing_sysfs(self):
with patch.object(
self.lease, "validate", side_effect=FileNotFoundError()
), patch.object(l.time, "monotonic", side_effect=[0, 3, 4]):
with self.assertRaisesRegex(l.LifetimeError, "Waiting"):
self.lease.release(self.match, Mock())
self.assertFalse((self.root / "rebind").exists())
def test_unrelated_or_userspace_events_do_not_authorize_slot_recovery(self):
packets = [
(self.event(SEQNUM="99"), [], 0, (0, 1)),
(self.event(DEVNUM="004"), [], 0, (0, 1)),
(self.event(DEVPATH="/devices/other"), [], 0, (0, 1)),
(self.event(), [], 0, (12345, 1)),
(self.event(DEVTYPE="usb_interface"), [], 0, (0, 1)),
BlockingIOError(),
]
self.lease.events.recvmsg.side_effect = packets
self.assertFalse(self.lease.poll_removed())
def test_lost_or_truncated_events_invalidate_recovery(self):
for fault in (
OSError(errno.ENOBUFS, "overflow"),
(self.event(), [], socket.MSG_TRUNC, (0, 1)),
):
self.lease.ready = True
self.lease.events.recvmsg.side_effect = [fault]
with self.assertRaises(l.LifetimeError):
self.lease.poll_removed()
self.assertFalse(self.lease.ready)
self.assertFalse((self.root / "rebind").exists())
def test_pinned_completed_removal_cleans_only_its_slot(self):
self.lease.events.recvmsg.side_effect = [
(self.event(), [], 0, (0, 1)),
BlockingIOError(),
]
unbind = Mock()
self.lease.release(self.match, unbind)
unbind.assert_not_called()
self.assertEqual((self.root / "rebind").read_text(), "1-2")
self.assertFalse(self.lease.ready)
def test_present_device_uses_native_unbind_not_absent_recovery(self):
self.match.read_text.return_value = ""
self.match.read_text.side_effect = None
unbind = Mock()
with patch.object(self.lease, "validate"):
self.lease.release(self.match, unbind)
unbind.assert_called_once_with("1-2")
self.assertFalse((self.root / "rebind").exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,303 @@
"""Real socket control/URB tests with disposable sysfs-shaped device objects."""
import importlib.util
import os
from pathlib import Path
import socket
import socketserver
import struct
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location(
"usbip_protocol", ROOT / "scripts/devices/usbip_protocol.py"
)
p = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p)
def descriptor(
name="2-1", bus=2, number=3, vendor=0x1D6B, product=0x0104, interfaces=1
):
return p.DEVICE.pack(
b"/sys/devices/disposable",
name.encode(),
bus,
number,
3,
vendor,
product,
0x0100,
0,
0,
0,
1,
1,
interfaces,
)
def exact(connection, count):
result = bytearray()
while len(result) < count:
block = connection.recv(count - len(result))
if not block:
break
result.extend(block)
return bytes(result)
class GateTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="usbip-gate-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.sysfs = self.root / "sysfs"
self.sysfs.mkdir()
self.device("original")
self.selected = {
"2-1": {
"id": "abcdef123456",
"vendor": "1d6b",
"product": "0104",
"serial": "selected-serial",
}
}
self.calls = []
self.reply = descriptor()
self.listed = [
(descriptor(), b"\x02\x02\x01\0"),
(descriptor("3-1", bus=3, number=4), b"\x02\x02\x01\0"),
]
self.hook = lambda: None
self.header_override = None
fixture = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
try:
header = exact(self.request, 8)
if len(header) != 8:
return
_, code, _ = p.HEADER.unpack(header)
request = header + (
exact(self.request, 32) if code == p.IMPORT else b""
)
fixture.calls.append(request)
fixture.hook()
self.request.sendall(
fixture.header_override
or p.HEADER.pack(p.VERSION, code & 0x7FFF, 0)
)
if code == p.DEVLIST:
self.request.sendall(struct.pack("!I", len(fixture.listed)))
for raw, interfaces in fixture.listed:
self.request.sendall(raw + interfaces)
return
self.request.sendall(fixture.reply)
while True:
block = self.request.recv(65536)
if not block:
self.request.shutdown(socket.SHUT_WR)
return
self.request.sendall(block)
except OSError:
return
class Backend(socketserver.ThreadingMixIn, socketserver.TCPServer):
daemon_threads = True
self.backend = Backend(("127.0.0.1", 0), Handler)
self.backend_thread = threading.Thread(
target=self.backend.serve_forever,
kwargs={"poll_interval": 0.05},
daemon=True,
)
self.backend_thread.start()
self.addCleanup(self.backend.server_close)
self.addCleanup(self.backend.shutdown)
# Production calls this as root; these protocol tests do not alter users.
with patch.object(p.os, "chown"):
self.gate = p.Gate(
self.root / "gate.sock",
os.getgid(),
lambda name: self.selected.get(name),
self.sysfs,
self.backend.server_address,
max_connections=2,
)
self.addCleanup(self.gate.terminate)
def device(self, directory, serial="selected-serial", number=3):
device = self.root / directory
device.mkdir()
for name, value in {
"idVendor": "1d6b",
"idProduct": "0104",
"serial": serial,
"busnum": "2",
"devnum": str(number),
"bDeviceClass": "00",
}.items():
(device / name).write_text(value)
interface = device / "2-1:1.0"
interface.mkdir()
(interface / "bInterfaceClass").write_text("02")
(device / "driver").symlink_to("/sys/bus/usb/drivers/usbip-host")
link = self.sysfs / "2-1"
link.unlink(missing_ok=True)
link.symlink_to(device, target_is_directory=True)
return device
def connect(self):
connection = socket.socket(socket.AF_UNIX)
connection.settimeout(3)
connection.connect(str(self.gate.path))
self.addCleanup(connection.close)
return connection
def request(self, name=b"2-1\0", code=p.IMPORT):
return p.HEADER.pack(p.VERSION, code, 0) + (
name.ljust(32, b"\0") if code == p.IMPORT else b""
)
def reject(self, request):
connection = self.connect()
connection.sendall(request)
result = exact(connection, 8)
self.assertTrue(not result or p.HEADER.unpack(result)[2] != 0, result)
self.assertEqual(connection.recv(1), b"")
return result
def test_fragmented_selected_import_and_large_binary_full_duplex(self):
connection = self.connect()
for value in self.request():
connection.sendall(bytes([value]))
self.assertEqual(
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
)
payload = bytes(range(256)) * 4096
writer = threading.Thread(target=connection.sendall, args=(payload,))
writer.start()
self.assertEqual(exact(connection, len(payload)), payload)
writer.join(3)
self.assertFalse(writer.is_alive())
connection.shutdown(socket.SHUT_WR)
self.assertEqual(connection.recv(1), b"")
def test_foreign_device_is_rejected_before_native_backend_import(self):
self.reject(self.request(b"3-1\0"))
self.assertEqual(self.calls, [])
def test_malformed_busids_never_reach_backend(self):
for value in (b"2-1\0x", b"../../etc\0", b"2-1\xff\0", b"x" * 32):
with self.subTest(value=value):
self.reject(self.request(value))
self.assertEqual(self.calls, [])
def test_unsupported_control_headers_never_reach_backend(self):
for header in (
p.HEADER.pack(0x0110, p.IMPORT, 0),
p.HEADER.pack(p.VERSION, 0x8001, 0),
p.HEADER.pack(p.VERSION, p.IMPORT, 1),
):
self.reject(header)
self.assertEqual(self.calls, [])
def test_devlist_filters_unselected_export_and_preserves_interface_records(self):
connection = self.connect()
connection.sendall(self.request(code=p.DEVLIST))
reply = exact(connection, 12)
self.assertEqual(reply, p.HEADER.pack(p.VERSION, 5, 0) + struct.pack("!I", 1))
self.assertEqual(exact(connection, 316), self.reply + b"\x02\x02\x01\0")
self.assertEqual(connection.recv(1), b"")
def test_changed_selection_during_import_receives_no_success_payload(self):
self.hook = self.selected.clear
self.reject(self.request())
self.assertEqual(len(self.calls), 1)
def test_mutated_callback_dictionary_cannot_rewrite_saved_selection(self):
self.hook = lambda: self.selected["2-1"].update(id="fedcba654321")
self.reject(self.request())
def test_replaced_sysfs_object_cannot_use_identical_reply_metadata(self):
self.hook = lambda: self.device("replacement")
self.reject(self.request())
def test_driver_change_during_import_is_rejected(self):
def replace():
driver = self.root / "original/driver"
driver.unlink()
driver.symlink_to("/sys/bus/usb/drivers/usb")
self.hook = replace
self.reject(self.request())
def test_descriptor_identity_and_devnum_must_match_pinned_object(self):
for reply in (
descriptor(number=4),
descriptor(vendor=0x05AC),
descriptor("3-1"),
descriptor(bus=3),
):
self.reply = reply
self.reject(self.request())
def test_composite_storage_or_hub_device_is_rejected_before_backend(self):
for name in ("bDeviceClass", "2-1:1.0/bInterfaceClass"):
path = self.root / "original" / name
old = path.read_text()
for value in ("08", "09"):
path.write_text(value)
self.reject(self.request())
path.write_text(old)
self.assertEqual(self.calls, [])
def test_unicode_serial_is_valid_without_changing_busid_encoding(self):
serial = "selected-ñ-设备"
(self.root / "original/serial").write_text(serial)
self.selected["2-1"]["serial"] = serial
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(
exact(connection, 320), p.HEADER.pack(p.VERSION, 3, 0) + self.reply
)
def test_backend_response_version_opcode_and_status_are_validated(self):
for header in (
p.HEADER.pack(0x0110, 3, 0),
p.HEADER.pack(p.VERSION, 5, 0),
p.HEADER.pack(p.VERSION, 3, 0xFFFFFFFF),
):
self.header_override = header
self.reject(self.request())
def test_incomplete_header_times_out_but_accepted_idle_stream_does_not(self):
with patch.object(p, "CONTROL_SECONDS", 0.1):
partial = self.connect()
partial.sendall(b"\x01")
self.assertEqual(partial.recv(1), b"")
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(len(exact(connection, 320)), 320)
time.sleep(0.2)
connection.sendall(b"idle-still-works")
self.assertEqual(exact(connection, 16), b"idle-still-works")
def test_stop_closes_idle_backend_and_client_and_removes_listener(self):
connection = self.connect()
connection.sendall(self.request())
self.assertEqual(len(exact(connection, 320)), 320)
self.gate.terminate()
self.assertEqual(connection.recv(1), b"")
self.assertFalse(self.gate.path.exists())
self.assertIsNotNone(self.gate.poll())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,79 @@
name: Native Monitor AppImage validation
on:
pull_request:
paths:
- '.github/workflows/test-appimage-native.yml'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/scripts/appimage_arch.sh'
- 'AppImage/scripts/borg_native_arm.sh'
- '.github/scripts/smoke_native_apprun.sh'
- '.github/scripts/check_appimage_elf.py'
- 'AppImage/scripts/AppRun'
- 'tests/test_appimage_arch.py'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-appimage-${{ github.ref }}
cancel-in-progress: true
jobs:
native-build:
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-22.04
arch: x86_64
- runner: ubuntu-22.04-arm
arch: aarch64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Validate native architecture selection
run: |
test "$(uname -m)" = '${{ matrix.arch }}'
python -B -m unittest discover -s tests -p test_appimage_arch.py -v
- name: Refresh package indexes for bundled monitoring tools
# Third-party runner repositories occasionally fail; the build reports missing tools.
run: sudo apt-get update || true
- name: Build native AppImage without publishing it
run: bash AppImage/scripts/build_appimage.sh
- name: Reject foreign ELF payloads
run: python .github/scripts/check_appimage_elf.py /tmp/proxmenux_build/ProxMenux.AppDir '${{ matrix.arch }}'
- name: Extract native AppImage without FUSE
run: |
mkdir /tmp/proxmenux-native-extract
cd /tmp/proxmenux-native-extract
image=$(find "$GITHUB_WORKSPACE/AppImage/dist" -name '*.AppImage' -type f -print -quit)
test -n "$image"
chmod +x "$image"
"$image" --appimage-extract
test -f squashfs-root/usr/bin/flask_server.py
test -f squashfs-root/usr/bin/appimage_arch.sh
test -f squashfs-root/web/index.html
- name: Import bundled Python 3.11 native WebSocket runtime
env:
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp311:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
run: |
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Import bundled Python 3.13 native WebSocket runtime
env:
PYTHONPATH: /tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/vendor/cp313:/tmp/proxmenux-native-extract/squashfs-root/usr/lib/python3/dist-packages
run: |
python -c 'import gevent, greenlet, geventwebsocket, zope.interface; from PIL import Image; from gevent import pywsgi; print(gevent.__version__)'
bash .github/scripts/smoke_native_apprun.sh /tmp/proxmenux-native-extract/squashfs-root
+59 -2
View File
@@ -7,7 +7,9 @@ on:
- 'AppImage/scripts/notification_manager.py'
- 'AppImage/scripts/notification_templates.py'
- 'AppImage/scripts/ai_context_enrichment.py'
- 'AppImage/scripts/ai_providers/base.py'
- 'AppImage/scripts/ai_providers/**'
- 'AppImage/config/verified_ai_models.json'
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
@@ -30,6 +32,11 @@ on:
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/ovs_topology.py'
- 'AppImage/scripts/tests/test_ovs_topology.py'
- 'AppImage/components/network-flow.tsx'
- 'AppImage/components/network-metrics.tsx'
- 'AppImage/tests/ovs-network-flow.cjs'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/scripts/build_appimage.sh'
- 'AppImage/components/guest-backup-list.tsx'
@@ -72,7 +79,9 @@ on:
- 'AppImage/scripts/notification_manager.py'
- 'AppImage/scripts/notification_templates.py'
- 'AppImage/scripts/ai_context_enrichment.py'
- 'AppImage/scripts/ai_providers/base.py'
- 'AppImage/scripts/ai_providers/**'
- 'AppImage/config/verified_ai_models.json'
- 'AppImage/scripts/tests/test_groq_model_recommendations.py'
- 'AppImage/scripts/tests/test_backup_ai_diagnostics.py'
- 'AppImage/scripts/notification_channels.py'
- 'AppImage/components/apprise-service-help.tsx'
@@ -89,6 +98,11 @@ on:
- 'AppImage/scripts/flask_proxmenux_routes.py'
- 'AppImage/scripts/guest_backup_archives.py'
- 'AppImage/scripts/flask_server.py'
- 'AppImage/scripts/ovs_topology.py'
- 'AppImage/scripts/tests/test_ovs_topology.py'
- 'AppImage/components/network-flow.tsx'
- 'AppImage/components/network-metrics.tsx'
- 'AppImage/tests/ovs-network-flow.cjs'
- 'AppImage/scripts/jwt_middleware.py'
- 'AppImage/components/guest-backup-list.tsx'
- 'AppImage/components/virtual-machines.tsx'
@@ -190,6 +204,19 @@ jobs:
bash -n scripts/gpu_tpu/nvidia_installer.sh
python3 -B -m unittest discover -s tests -p 'test_nvidia*.py' -v
ai-model-availability:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Test model ordering and curated availability without network or credentials
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_groq_model_recommendations.py -v
installer-and-release:
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -408,3 +435,33 @@ jobs:
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
native-image-import:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Exercise private images with isolated mapped Linux readers
run: sudo /usr/bin/python3 -B -m unittest discover -s oci/tests -p test_native_import_access.py -v
network-topology:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- uses: actions/setup-node@v6
with:
node-version: '22.14.0'
- name: Test read-only OVS discovery and mixed network API
run: python -B -m unittest discover -s AppImage/scripts/tests -p test_ovs_topology.py -v
- name: Test both Network Flow layouts
run: |
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/ovs-network-flow.cjs
+50
View File
@@ -0,0 +1,50 @@
name: USB/IP over SSH tests
on:
pull_request:
paths:
- 'scripts/devices/**'
- 'scripts/menus/hw_grafics_menu.sh'
- 'scripts/menus/main_menu.sh'
- 'scripts/menus/search_index.json'
- '.github/scripts/tests/test_usbip*.py'
- '.github/scripts/check_usbip_transport.py'
- '.github/workflows/test-usbip.yml'
- 'docs/usbip.md'
push:
branches: [main, develop]
paths:
- 'scripts/devices/**'
- '.github/scripts/tests/test_usbip*.py'
- '.github/scripts/check_usbip_transport.py'
- '.github/workflows/test-usbip.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
usbip:
runs-on: ubuntu-latest
timeout-minutes: 8
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.13']
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: Device ownership, reconnect and systemd validation
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -B -m unittest discover -s .github/scripts/tests -p 'test_usbip*.py' -v
- name: Install fixture-only transport dependencies
run: |
sudo apt-get update -o APT::Update::Error-Mode=any
sudo apt-get install -y socat openssh-client openssh-server iproute2 util-linux
- name: Real SSH and private namespace transport, without USB hardware
run: sudo "$(command -v python3)" -B .github/scripts/check_usbip_transport.py
BIN
View File
Binary file not shown.
@@ -0,0 +1 @@
69ed639710aba5804e3b8f33383357f3bcc981b94a5e9b272d214f31e2e87563 ProxMenux-1.2.8.AppImage
+12 -1
View File
@@ -397,6 +397,10 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
lightTheme: `${SELFHST_WEBP_BASE}/frigate-dark.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/frigate-light.webp`,
},
"hermes-agent": {
lightTheme: `${SELFHST_WEBP_BASE}/hermes-agent.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/hermes-agent-light.webp`,
},
"llama-cpp": {
lightTheme: `${SELFHST_WEBP_BASE}/llama-cpp.webp`,
darkTheme: `${SELFHST_WEBP_BASE}/llama-cpp-light.webp`,
@@ -415,9 +419,16 @@ const SELFHST_THEME_LOGOS: Record<string, { lightTheme: string; darkTheme: strin
},
}
// Earlier catalog icons that now have a themed selfhst equivalent.
const THEMED_LOGO_ALIASES: Record<string, string> = {
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/hermes.webp": "hermes-agent",
}
function selfhstThemeLogos(src: string) {
const alias = THEMED_LOGO_ALIASES[src.toLowerCase()]
if (alias) return SELFHST_THEME_LOGOS[alias]
if (!src.toLowerCase().includes("cdn.jsdelivr.net/gh/selfhst/icons")) return null
const match = src.toLowerCase().match(/\/(frigate|llama-cpp|portainer|tailscale|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
const match = src.toLowerCase().match(/\/(frigate|hermes-agent|llama-cpp|portainer|tailscale|vaultwarden)(?:-(?:dark|light))?\.webp(?:[?#].*)?$/)
return match ? SELFHST_THEME_LOGOS[match[1]] : null
}
+4 -1
View File
@@ -62,7 +62,7 @@ type Bridge = {
type Guest = {
id: string
label: string
kind: "lxc" | "vm" | "host"
kind: "lxc" | "vm" | "host" | "interface"
bridge: string
rx: number
tx: number
@@ -87,6 +87,8 @@ const ICONS: Record<string, string> = {
vm: `<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>`,
}
ICONS.interface = ICONS.bridge
const COLORS = {
host: "var(--amber-500, #f59e0b)",
lxc: "var(--cyan-500, #06b6d4)",
@@ -97,6 +99,7 @@ const COLORS = {
// just another NIC.
bond: "var(--orange-500, #f97316)",
bridge: "var(--cyan-500, #06b6d4)",
interface: "var(--cyan-500, #06b6d4)",
gray: "#525252",
}
+12 -4
View File
@@ -101,6 +101,9 @@ interface NetworkInterface {
// Master device resolved from /sys/class/net/<iface>/master — the
// bridge for a guest tap, the bond for a slave NIC.
bridge_owner?: string
network_backend?: "ovs"
ovs_tag?: number | null
ovs_trunks?: number[]
bridge_members?: string[]
bridge_physical_interface?: string
bridge_bond_slaves?: string[]
@@ -568,6 +571,7 @@ export function NetworkMetrics() {
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
]
const vmLxcInterfaces = (networkData.vm_lxc_interfaces || []).sort((a, b) => {
@@ -867,6 +871,7 @@ export function NetworkMetrics() {
...(networkData.bond_interfaces || []),
...(networkData.bridge_interfaces || []),
...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
]
const flowData: NetworkFlowData = {
nics: (networkData.physical_interfaces || []).map((p) => ({
@@ -916,7 +921,9 @@ export function NetworkMetrics() {
tx: runningGuests.reduce((a, v) => a + toMBps(v.tx_Bps), 0),
}
})(),
...(networkData.vm_lxc_interfaces || []).map((v) => {
...[...(networkData.vm_lxc_interfaces || []),
...(networkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
].map((v) => {
// Authoritative bridge from the kernel (read by the
// backend from /sys/class/net/<iface>/master). Fallback
// to bridge_members scan, then first bridge as last
@@ -929,8 +936,8 @@ export function NetworkMetrics() {
(networkData.bridge_interfaces?.[0]?.name || "")
return {
id: v.name,
label: v.vm_name || v.name,
kind: (v.vm_type === "vm" ? "vm" : "lxc") as "vm" | "lxc",
label: v.vm_name || (v.ovs_tag != null ? `${v.name} · VLAN ${v.ovs_tag}` : v.name),
kind: v.type === "vlan" ? "interface" as const : (v.vm_type === "vm" ? "vm" as const : "lxc" as const),
bridge: ownerName,
rx: toMBps(v.rx_Bps),
tx: toMBps(v.tx_Bps),
@@ -1213,6 +1220,7 @@ export function NetworkMetrics() {
...(modalNetworkData.bond_interfaces || []),
...(modalNetworkData.bridge_interfaces || []),
...(modalNetworkData.vm_lxc_interfaces || []),
...(modalNetworkData.interfaces || []).filter((i) => i.network_backend === "ovs" && i.type === "vlan"),
].find((iface) => iface.name === selectedInterface.name)
: selectedInterface
@@ -1298,7 +1306,7 @@ export function NetworkMetrics() {
</div>
<div>
<div className="text-sm text-muted-foreground">MTU</div>
<div className="font-medium">{displayInterface.mtu}</div>
<div className="font-medium">{displayInterface.mtu || t("common.notAvailable")}</div>
</div>
{displayInterface.mac_address && (
<div className="col-span-2">
+2 -4
View File
@@ -110,12 +110,10 @@
"openai/gpt-oss-120b",
"meta-llama/llama-3.1-8b-instruct",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"nvidia/nemotron-3-super-120b-a12b:free",
"google/gemma-4-26b-a4b-it:free",
"poolside/laguna-s-2.1:free"
"google/gemma-4-26b-a4b-it:free"
],
"recommended": "meta-llama/llama-3.3-70b-instruct",
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test. Reviewed 2026-10-26: three `:free` variants no longer served by OpenRouter were dropped."
"_note": "Curated manually from the 249+ models OpenRouter serves — the auto-verifier passes ~200 today but only a subset are practical for notification translation. Includes the 11 previously-curated paid models plus 22 new mainstream additions (gpt-5.1, claude-opus-4.8, sonnet-5, grok-4.3/4.20, kimi-k2, deepseek-v3.2, nova, cohere-a, sonar, ...) and 6 `:free` variants re-added manually — the free tier is intentionally supported per user request even though today's rate limits blocked some of them from passing the test. Reviewed 2026-10-26: three `:free` variants no longer served by OpenRouter were dropped. Availability reviewed 2026-11-04 against the public models endpoint: expired poolside/laguna-s-2.1:free and unlisted nvidia/nemotron-3-super-120b-a12b:free removed. This availability check is not a new inference verification; no untested models were added."
},
"ollama": {
"_note": "Ollama models are local, we don't filter them. User manages their own models.",
+4 -1
View File
@@ -6,8 +6,11 @@
# Get the directory where this AppImage is mounted
APPDIR="$(dirname "$(readlink -f "${0}")")"
source "${APPDIR}/usr/bin/appimage_arch.sh" || exit 1
pmx_appimage_arch "$(uname -m)" || exit 1
export PATH="${APPDIR}/usr/bin:${PATH}"
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu:${APPDIR}/usr/lib:${APPDIR}/lib/x86_64-linux-gnu:${APPDIR}/lib:${LD_LIBRARY_PATH}"
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/${PMX_APPIMAGE_TRIPLET}:${APPDIR}/usr/lib:${APPDIR}/lib/${PMX_APPIMAGE_TRIPLET}:${APPDIR}/lib:${LD_LIBRARY_PATH}"
# Select the native dependency tree matching the host interpreter. PVE 8
# uses CPython 3.11 and PVE 9 uses CPython 3.13; both runtimes are bundled in
@@ -21,8 +21,13 @@ class GroqProvider(AIProvider):
# Exclude non-chat models
EXCLUDED_PATTERNS = ['whisper', 'tts', 'guard', 'tool-use']
# Recommended models (in priority order - versatile/large models first)
RECOMMENDED_PREFIXES = ['llama-3.3', 'llama-3.1-70b', 'llama-3.1-8b', 'mixtral', 'gemma']
# Prefer the verified default and current GPT-OSS models. Llama remains
# available for accounts with access; retired Mixtral/Gemma families have
# no recommendation priority. Sorting never adds IDs to the API response.
RECOMMENDED_PREFIXES = [
'openai/gpt-oss-20b', 'openai/gpt-oss-120b', 'qwen/qwen3.8-27b',
'llama-3.3', 'llama-3.1-8b',
]
def list_models(self) -> List[str]:
"""List available Groq models for chat completions.
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Shared native build/runtime architecture names. No emulated cross-builds.
pmx_appimage_arch() {
case "$1" in
x86_64|amd64)
PMX_APPIMAGE_ARCH=x86_64
PMX_APPIMAGE_TRIPLET=x86_64-linux-gnu
PMX_APPIMAGE_SUFFIX=""
;;
aarch64|arm64)
PMX_APPIMAGE_ARCH=aarch64
PMX_APPIMAGE_TRIPLET=aarch64-linux-gnu
PMX_APPIMAGE_SUFFIX=-aarch64
;;
*)
echo "Unsupported Monitor AppImage architecture: $1" >&2
return 1
;;
esac
}
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Borg 1.2.8 has no official ARM64 standalone release. Never ship or run
# its AMD64 executable on ARM; use the host's native distro package instead.
if [ -x /usr/bin/borg ]; then
# The distro package must use its own Python modules and shared libraries.
unset PYTHONPATH PYTHONHOME LD_LIBRARY_PATH
exec /usr/bin/borg "$@"
fi
echo 'Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.' >&2
exit 127
+39 -16
View File
@@ -8,6 +8,8 @@ set -e
WORK_DIR="/tmp/proxmenux_build"
APP_DIR="$WORK_DIR/ProxMenux.AppDir"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/appimage_arch.sh"
pmx_appimage_arch "$(uname -m)" || exit 1
DIST_DIR="$SCRIPT_DIR/../dist"
APPIMAGE_ROOT="$SCRIPT_DIR/.."
@@ -24,7 +26,7 @@ ZOPE_EVENT_VERSION="6.2"
GEVENT_WEBSOCKET_VERSION="0.10.1"
VERSION=$(node -p "require('$APPIMAGE_ROOT/package.json').version")
APPIMAGE_NAME="ProxMenux-${VERSION}.AppImage"
APPIMAGE_NAME="ProxMenux-${VERSION}${PMX_APPIMAGE_SUFFIX}.AppImage"
echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitoring tools..."
@@ -33,17 +35,18 @@ echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitor
# under the non-root GitHub Actions runner — previously hardcoded to
# /var/cache/, which the CI user can't write to (mkdir Permission
# denied → build aborted before doing any actual work).
APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool"
APPIMAGETOOL_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/proxmenux-build/appimagetool-${PMX_APPIMAGE_ARCH}"
# Preserve a cached copy of appimagetool across builds. wget -q has bitten
# us repeatedly when GitHub momentarily rate-limits or the runner has no
# network — the result is a 0-byte file that passes the `[ -f ]` check on
# the next run and breaks the build silently.
if [ -f "$WORK_DIR/appimagetool" ] && [ -s "$WORK_DIR/appimagetool" ]; then
mkdir -p "$(dirname "$APPIMAGETOOL_CACHE")"
cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE"
APPIMAGETOOL_URL="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${PMX_APPIMAGE_ARCH}.AppImage"
if [ "$PMX_APPIMAGE_ARCH" = aarch64 ]; then
# The old AppImageKit ARM build does not accept ARCH=aarch64 reliably.
# Use a versioned tool; leave the existing AMD64 tool unchanged.
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-aarch64.AppImage"
APPIMAGETOOL_CACHE="${APPIMAGETOOL_CACHE}-1.9.1"
fi
# Architecture-specific caches never reuse an executable from a different CPU.
# Clean and create work directory
rm -rf "$WORK_DIR"
mkdir -p "$APP_DIR"
@@ -57,7 +60,7 @@ if [ -s "$APPIMAGETOOL_CACHE" ]; then
fi
if [ ! -s "$WORK_DIR/appimagetool" ]; then
echo "📥 Downloading appimagetool..."
wget --tries=3 --timeout=60 "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" -O "$WORK_DIR/appimagetool" || true
wget --tries=3 --timeout=60 "$APPIMAGETOOL_URL" -O "$WORK_DIR/appimagetool" || true
if [ ! -s "$WORK_DIR/appimagetool" ]; then
echo "❌ Failed to download appimagetool" >&2
exit 1
@@ -67,6 +70,17 @@ if [ ! -s "$WORK_DIR/appimagetool" ]; then
cp -f "$WORK_DIR/appimagetool" "$APPIMAGETOOL_CACHE"
fi
APPIMAGETOOL_ARGS=()
if [ "$PMX_APPIMAGE_ARCH" = aarch64 ]; then
echo "f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 $WORK_DIR/appimagetool" | sha256sum -c -
# Without --runtime-file, appimagetool 1.9.1 embeds the unpinned
# `continuous` runtime. Use a released runtime checked by its digest.
RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-aarch64"
wget --tries=3 --timeout=60 "$RUNTIME_URL" -O "$WORK_DIR/runtime-aarch64"
echo "00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 $WORK_DIR/runtime-aarch64" | sha256sum -c -
APPIMAGETOOL_ARGS=(--runtime-file "$WORK_DIR/runtime-aarch64")
fi
# Create directory structure
mkdir -p "$APP_DIR/usr/bin"
mkdir -p "$APP_DIR/usr/lib/python3/dist-packages"
@@ -126,6 +140,7 @@ cd "$SCRIPT_DIR"
# Copy Flask server
echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/zram_metrics.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
@@ -245,6 +260,7 @@ fi
# hb_ensure_borg used to download on demand — kept in lockstep so both
# code paths see the same version semantics. SHA256 is the upstream
# release checksum; bump both together.
if [ "$PMX_APPIMAGE_ARCH" = x86_64 ]; then
BORG_VERSION="1.2.8"
BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64"
BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
@@ -280,6 +296,12 @@ else
exit 1
fi
else
echo "⚠️ No official Borg 1.2.8 ARM64 standalone binary; using native-host adapter"
cp "$SCRIPT_DIR/borg_native_arm.sh" "$APP_DIR/usr/bin/borg"
chmod +x "$APP_DIR/usr/bin/borg"
fi
# Copy Next.js build
echo "📋 Copying web dashboard..."
if [ -d "$APPIMAGE_ROOT/out" ]; then
@@ -310,6 +332,7 @@ fi
echo "📋 Copying AppRun script..."
if [ -f "$SCRIPT_DIR/AppRun" ]; then
cp "$SCRIPT_DIR/AppRun" "$APP_DIR/AppRun"
cp "$SCRIPT_DIR/appimage_arch.sh" "$APP_DIR/usr/bin/appimage_arch.sh"
chmod +x "$APP_DIR/AppRun"
echo "✅ AppRun script copied successfully"
else
@@ -379,7 +402,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
# Phase 3b: Install the complete gevent/WSS runtime separately for every
# supported host ABI. Keeping each dependency tree isolated avoids mixing
# CPython-specific modules from the build runner with those loaded by PVE.
# The two --platform values allow gevent's manylinux_2_28 wheels and the
# The three --platform values allow gevent's manylinux_2_28 wheels and the
# backwards-compatible manylinux2014 zope.interface wheels to be resolved in
# one deterministic installation.
install_gevent_runtime() {
@@ -394,9 +417,9 @@ install_gevent_runtime() {
--target "$target" \
--upgrade \
--only-binary=:all: \
--platform manylinux_2_28_x86_64 \
--platform manylinux_2_27_x86_64 \
--platform manylinux2014_x86_64 \
--platform "manylinux_2_28_${PMX_APPIMAGE_ARCH}" \
--platform "manylinux_2_27_${PMX_APPIMAGE_ARCH}" \
--platform "manylinux2014_${PMX_APPIMAGE_ARCH}" \
--implementation cp \
--python-version "$python_version" \
--abi "$abi" \
@@ -547,7 +570,7 @@ if [ -d "$APP_DIR/bin" ]; then
fi
echo "🔍 Sanity check (ldd + presence of libfreeipmi)"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/x86_64-linux-gnu:$APP_DIR/usr/lib:$APP_DIR/usr/lib/x86_64-linux-gnu"
export LD_LIBRARY_PATH="$APP_DIR/lib:$APP_DIR/lib/${PMX_APPIMAGE_TRIPLET}:$APP_DIR/usr/lib:$APP_DIR/usr/lib/${PMX_APPIMAGE_TRIPLET}"
if ! find "$APP_DIR/usr/lib" "$APP_DIR/lib" -maxdepth 3 -name 'libfreeipmi.so.17*' | grep -q .; then
echo "❌ libfreeipmi.so.17 not found inside AppDir (ipmitool will fail)"
@@ -608,7 +631,7 @@ echo "🔨 Building unified AppImage v${VERSION}..."
cd "$WORK_DIR"
export NO_CLEANUP=1
export APPIMAGE_EXTRACT_AND_RUN=1
ARCH=x86_64 ./appimagetool --no-appstream --verbose "$APP_DIR" "$APPIMAGE_NAME"
ARCH="$PMX_APPIMAGE_ARCH" ./appimagetool --no-appstream --verbose "${APPIMAGETOOL_ARGS[@]}" "$APP_DIR" "$APPIMAGE_NAME"
# Move to dist directory
mv "$APPIMAGE_NAME" "$DIST_DIR/"
+48 -6
View File
@@ -53,6 +53,7 @@ import threading
import urllib.parse
import uuid
import disk_identity
import ovs_topology
import hardware_monitor
from health_persistence import health_persistence
import xml.etree.ElementTree as ET
@@ -6258,17 +6259,24 @@ def _iface_master(iface_name):
# Chain: veth/tap → fwbr (intermediate) → fwpr (peer) → vmbr (real).
def _resolve_bridge_owner(iface_name):
import re
ovs_owners = ovs_topology.get_topology().get('owners', {})
if iface_name in ovs_owners:
return ovs_owners[iface_name]
master = _iface_master(iface_name)
if not master:
return None
if master.startswith('fwbr'):
fwpr = re.sub(r'^fwbr', 'fwpr', master)
fwpr = re.sub(r'i(\d+)$', r'p\1', fwpr)
# A Linux bridge reaches the firewall bridge through fwprNpM; an OVS
# bridge through its own internal port fwlnNoM.
suffix = master[len('fwbr'):]
links = (re.sub(r'i(\d+)$', r'p\1', 'fwpr' + suffix),
re.sub(r'i(\d+)$', r'o\1', 'fwln' + suffix))
try:
if os.path.exists(f'/sys/class/net/{fwpr}'):
real = _iface_master(fwpr)
if real:
return real
for link in links:
if os.path.exists(f'/sys/class/net/{link}'):
real = ovs_owners.get(link) or _iface_master(link)
if real:
return real
except Exception:
pass
return master
@@ -6351,6 +6359,10 @@ def get_interface_type(interface_name):
if interface_name.startswith(('tun', 'vnet', 'docker', 'virbr')):
return 'skip'
ovs_type = ovs_topology.get_topology().get('types', {}).get(interface_name)
if ovs_type:
return ovs_type
# Check if it's a bond — sysfs first (name-agnostic), then the
# "bondN" convention as fallback when /sys isn't readable.
if _is_bond_master(interface_name) or interface_name.startswith('bond'):
@@ -6590,6 +6602,7 @@ def get_network_info():
pass
vm_lxc_map = get_vm_lxc_names()
ovs = ovs_topology.get_topology()
# Get network interfaces
net_if_addrs = psutil.net_if_addrs()
@@ -6724,8 +6737,10 @@ def get_network_info():
# vmbr so a guest behind the Proxmox firewall still lands
# under its true bridge in the Network Flow diagram.
master = _resolve_bridge_owner(interface_name)
master = ovs.get('owners', {}).get(interface_name) or master
if master:
interface_info['bridge_owner'] = master
interface_info.update(ovs.get('ports', {}).get(interface_name, {}))
if interface_type == 'bond':
bond_info = get_bond_info(interface_name)
@@ -6738,6 +6753,12 @@ def get_network_info():
if interface_type == 'bridge':
bridge_info = get_bridge_info(interface_name)
if interface_name in ovs.get('bridges', {}):
bridge_info['members'] = ovs['bridges'][interface_name]
bridge_info['physical_interface'] = next((member for member in bridge_info['members']
if member in ovs.get('bonds', {}) or _is_physical_dev(member)), None)
bridge_info['bond_slaves'] = ovs.get('bonds', {}).get(bridge_info['physical_interface'], {}).get('bond_slaves', [])
interface_info['network_backend'] = 'ovs'
interface_info['bridge_members'] = bridge_info['members']
interface_info['bridge_physical_interface'] = bridge_info['physical_interface']
interface_info['bridge_physical_duplex'] = bridge_info['physical_duplex']
@@ -6758,6 +6779,22 @@ def get_network_info():
if interface_type == 'bond':
network_data['bond_interfaces'].append(interface_info)
# An OVS bond is an OVSDB port, not a kernel network interface.
for name, detail in ovs.get('bonds', {}).items():
slaves = [item for item in network_data['physical_interfaces']
if item['name'] in detail['bond_slaves']]
network_data['bond_interfaces'] = [item for item in network_data['bond_interfaces'] if item['name'] != name]
bond_interface = {
'name': name, 'type': 'bond', 'addresses': [],
'status': 'up' if any(item['status'] == 'up' for item in slaves) else 'down',
# OVSDB membership does not reveal active-backup capacity.
'speed': 0, 'duplex': 'unknown', 'mtu': 0, 'mac_address': None,
'rx_Bps': sum(item.get('rx_Bps', 0) for item in slaves),
'tx_Bps': sum(item.get('tx_Bps', 0) for item in slaves), **detail}
network_data['bond_interfaces'].append(bond_interface)
network_data['interfaces'] = [item for item in network_data['interfaces'] if item['name'] != name]
network_data['interfaces'].append(bond_interface)
network_data['physical_active_count'] = physical_active_count
network_data['physical_total_count'] = physical_total_count
network_data['bridge_active_count'] = bridge_active_count
@@ -6808,6 +6845,11 @@ def get_network_info():
bridges_per_nic.setdefault(slave, []).append(bridge['name'])
else:
bridges_per_nic.setdefault(phys, []).append(bridge['name'])
for name, members in ovs.get('bridges', {}).items():
for member in members:
for nic in ovs.get('bonds', {}).get(member, {}).get('bond_slaves', [member]):
if name not in bridges_per_nic.setdefault(nic, []):
bridges_per_nic[nic].append(name)
for phy in network_data['physical_interfaces']:
if phy['name'] in bridges_per_nic:
phy['used_by_bridges'] = bridges_per_nic[phy['name']]
+109
View File
@@ -0,0 +1,109 @@
"""Read-only OVSDB topology; Linux bridges continue to use their sysfs path.
OVS does not publish Linux bridge brif/master links, and an OVSBond has
no kernel netdev. Read the three OVSDB tables once per short cache window
instead of guessing from names or changing the host's network config.
"""
import json
import shutil
import subprocess
import threading
import time
_CACHE_SECONDS = 15
_lock = threading.Lock()
_cached_at = 0
_cached = {}
def _value(value):
if isinstance(value, list) and len(value) == 2:
if value[0] in ('uuid', 'named-uuid'):
return value[1]
if value[0] == 'set':
return [_value(item) for item in value[1]]
return value
def _rows(table):
return [{key: _value(value) for key, value in zip(table['headings'], row)}
for row in table['data']]
def _list(value):
return value if isinstance(value, list) else [] if value is None or value == '' else [value]
def parse_topology(bridges, ports, interfaces):
"""Normalize OVSDB UUID references, including singleton sets and bonds."""
iface_by_id = {row['_uuid']: row for row in _rows(interfaces)}
port_by_id = {row['_uuid']: row for row in _rows(ports)}
topology = {'types': {}, 'owners': {}, 'bridges': {}, 'bonds': {}, 'ports': {}}
for bridge in _rows(bridges):
name = bridge['name']
topology['types'][name] = 'bridge'
members = []
for port_id in _list(bridge.get('ports')):
port = port_by_id.get(port_id)
if not port:
continue
port_name = port['name']
ifaces = [iface_by_id[i] for i in _list(port.get('interfaces')) if i in iface_by_id]
# The bridge's own internal port is not a downstream member.
if port_name == name:
continue
members.append(port_name)
detail = {'bridge_owner': name, 'network_backend': 'ovs',
'ovs_tag': next(iter(_list(port.get('tag'))), None),
'ovs_trunks': _list(port.get('trunks'))}
topology['ports'][port_name] = detail
topology['owners'][port_name] = name
is_bond = len(ifaces) > 1
if is_bond:
topology['types'][port_name] = 'bond'
mode = port.get('bond_mode') or 'balance-slb'
topology['bonds'][port_name] = {
'bond_mode': mode, 'bond_slaves': [i['name'] for i in ifaces],
# OVSDB alone does not tell us which slave is active.
'bond_active_slave': None, 'bond_supports_failover': False,
'bond_slave_status': {}, **detail}
for iface in ifaces:
iface_name = iface['name']
topology['owners'][iface_name] = name
topology['ports'][iface_name] = detail
if is_bond:
topology['ports'][iface_name] = {**detail, 'bond_master': port_name,
'bond_role': 'member'}
elif iface.get('type') == 'internal' and not iface_name.startswith(('fwpr', 'fwln')):
topology['types'][iface_name] = 'vlan'
topology['bridges'][name] = members
return topology
def get_topology():
"""Bounded, cached reads; missing OVS or an unavailable daemon is harmless."""
global _cached_at, _cached
with _lock:
now = time.monotonic()
if now - _cached_at < _CACHE_SECONDS:
return _cached
binary = shutil.which('ovs-vsctl')
result = {}
if binary:
try:
tables = []
for table, columns in (
('Bridge', '_uuid,name,ports'),
('Port', '_uuid,name,interfaces,bond_mode,tag,trunks'),
('Interface', '_uuid,name,type'),
):
proc = subprocess.run(
[binary, '--timeout=1', '--format=json', f'--columns={columns}', 'list', table],
capture_output=True, text=True, timeout=2, check=True)
tables.append(json.loads(proc.stdout))
result = parse_topology(*tables)
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError):
# Do not keep stale ownership when OVS disappears or is reconfigured.
result = {}
_cached, _cached_at = result, now
return _cached
@@ -0,0 +1,52 @@
"""Provider model ordering and curated availability, without credentials/network."""
import io
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from ai_providers.groq_provider import GroqProvider
class GroqModelRecommendationsTests(unittest.TestCase):
def fetch(self, ids):
response = io.BytesIO(json.dumps({'data': [{'id': model} for model in ids]}).encode())
with patch('ai_providers.groq_provider.urllib.request.urlopen', return_value=response) as request:
models = GroqProvider(api_key='fixture-only').list_models()
self.assertEqual(request.call_args.kwargs['timeout'], 10)
return models
def test_current_verified_models_precede_legacy_and_unknown_families(self):
listed = ['mixtral-fixture', 'allam-2-7b', 'llama-3.3-70b-versatile',
'openai/gpt-oss-120b', 'qwen/qwen3.8-27b', 'openai/gpt-oss-20b']
self.assertEqual(self.fetch(listed)[:3],
['openai/gpt-oss-20b', 'openai/gpt-oss-120b', 'qwen/qwen3.8-27b'])
self.assertCountEqual(self.fetch(listed), listed)
def test_recommendations_never_invent_models_or_remove_arabic_model(self):
self.assertEqual(self.fetch(['allam-2-7b']), ['allam-2-7b'])
self.assertEqual(self.fetch([]), [])
def test_non_chat_models_still_excluded(self):
self.assertEqual(self.fetch(['whisper-large-v3', 'llama-prompt-guard-2-22m',
'openai/gpt-oss-20b']), ['openai/gpt-oss-20b'])
def test_missing_credentials_do_not_fetch_models(self):
with patch('ai_providers.groq_provider.urllib.request.urlopen') as request:
self.assertEqual(GroqProvider().list_models(), [])
request.assert_not_called()
def test_curated_catalog_keeps_verified_default_and_removes_unavailable_free_ids(self):
catalog = json.loads((Path(__file__).resolve().parents[2] / 'config/verified_ai_models.json').read_text())
self.assertEqual(GroqProvider.RECOMMENDED_PREFIXES[0], catalog['groq']['recommended'])
for provider in ('groq', 'openrouter'):
self.assertIn(catalog[provider]['recommended'], catalog[provider]['models'])
self.assertNotIn('poolside/laguna-s-2.1:free', catalog['openrouter']['models'])
self.assertNotIn('nvidia/nemotron-3-super-120b-a12b:free', catalog['openrouter']['models'])
self.assertIn('allam-2-7b', catalog['groq']['models'])
if __name__ == '__main__':
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""OVSDB fixtures exercise live ownership, UUID sets, bonds and API output."""
import ast
import importlib.util
import json
import os
from pathlib import Path
import socket
import subprocess
import types
import unittest
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('ovs_topology_test', SCRIPTS / 'ovs_topology.py')
ovs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(ovs)
def uuid(name):
return ['uuid', name]
def refs(*names):
return ['set', [uuid(name) for name in names]]
def table(headings, *rows):
return {'headings': headings.split(','), 'data': list(rows)}
def fixture():
# Mixed Linux/OVS host, named bond without a kernel netdev, tagged
# internal port, direct guest and guest behind a Proxmox firewall,
# which on OVS links its fwbr bridge through the internal port fwlnNoM.
return (
table('_uuid,name,ports', ['b1', 'switch0', refs('p0', 'p1', 'p2', 'p3', 'p4', 'p5')]),
table('_uuid,name,interfaces,bond_mode,tag,trunks',
['p0', 'switch0', uuid('i0'), '', ['set', []], ['set', []]],
['p1', 'eno1', uuid('i1'), '', ['set', []], ['set', [10, 20]]],
['p2', 'uplink', refs('i2', 'i3'), 'balance-tcp', ['set', []], ['set', []]],
['p3', 'vlan100', uuid('i4'), '', 100, ['set', []]],
['p4', 'tap103i0', uuid('i5'), '', 100, ['set', []]],
['p5', 'fwln115o0', uuid('i6'), '', 10, ['set', []]]),
table('_uuid,name,type', ['i0', 'switch0', 'internal'], ['i1', 'eno1', ''],
['i2', 'eno2', ''], ['i3', 'eno3', ''], ['i4', 'vlan100', 'internal'],
['i5', 'tap103i0', ''], ['i6', 'fwln115o0', 'internal']))
class OvsTopology(unittest.TestCase):
def setUp(self):
ovs._cached_at = 0
ovs._cached = {}
self.addCleanup(setattr, ovs, '_cached_at', 0)
self.addCleanup(setattr, ovs, '_cached', {})
def test_ovsdb_singletons_bonds_tags_and_internal_ports(self):
data = ovs.parse_topology(*fixture())
self.assertEqual(data['types']['switch0'], 'bridge')
self.assertEqual(data['types']['vlan100'], 'vlan')
self.assertNotIn('switch0', data['bridges']['switch0'])
self.assertEqual(data['owners']['tap103i0'], 'switch0')
self.assertEqual(data['ports']['vlan100']['ovs_tag'], 100)
self.assertEqual(data['ports']['eno1']['ovs_trunks'], [10, 20])
self.assertEqual(data['bonds']['uplink']['bond_slaves'], ['eno2', 'eno3'])
self.assertIsNone(data['bonds']['uplink']['bond_active_slave'])
self.assertEqual(data['ports']['eno2']['bond_master'], 'uplink')
def test_zero_vlan_tag_is_preserved(self):
tables = fixture()
tables[1]['data'][3][4] = 0
self.assertEqual(ovs.parse_topology(*tables)['ports']['vlan100']['ovs_tag'], 0)
def test_no_ovs_does_not_spawn_any_process(self):
with patch.object(ovs.shutil, 'which', return_value=None), patch.object(ovs.subprocess, 'run') as run:
self.assertEqual(ovs.get_topology(), {})
run.assert_not_called()
def test_read_only_queries_cached_and_failure_drops_stale_topology(self):
replies = [types.SimpleNamespace(stdout=json.dumps(t)) for t in fixture()]
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
patch.object(ovs.subprocess, 'run', side_effect=replies) as run:
first = ovs.get_topology()
self.assertEqual(ovs.get_topology(), first)
self.assertEqual(run.call_count, 3)
for call in run.call_args_list:
self.assertEqual(call.args[0][-2], 'list')
self.assertIn('--timeout=1', call.args[0])
ovs._cached_at = 0
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
patch.object(ovs.subprocess, 'run', side_effect=subprocess.TimeoutExpired('ovs-vsctl', 2)):
self.assertEqual(ovs.get_topology(), {})
def test_malformed_or_reconfigured_database_is_harmless(self):
for payload in ('not-json', '{}', '{"headings": [], "data": [[]]}'):
ovs._cached_at = 0
with patch.object(ovs.shutil, 'which', return_value='/usr/bin/ovs-vsctl'), \
patch.object(ovs.subprocess, 'run', return_value=types.SimpleNamespace(stdout=payload)):
self.assertEqual(ovs.get_topology(), {})
def api_namespace(self):
names = {'get_network_info', 'get_interface_type', '_resolve_bridge_owner'}
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in names]
data = ovs.parse_topology(*fixture())
names = ['eno1', 'eno2', 'eno3', 'switch0', 'vlan100', 'tap103i0', 'tap115i0', 'vmbr1']
stats = {name: types.SimpleNamespace(isup=True, speed=1000, duplex=2, mtu=1500) for name in names}
counter = types.SimpleNamespace(bytes_sent=1000, bytes_recv=2000, packets_sent=10, packets_recv=20,
errin=0, errout=0, dropin=0, dropout=0)
ns = {'os': os, 'socket': socket,
'ovs_topology': types.SimpleNamespace(get_topology=lambda: data),
'get_proxmox_node_name': lambda: 'pve', 'get_vm_lxc_names': lambda: {},
'psutil': types.SimpleNamespace(net_if_addrs=lambda: dict.fromkeys(names, []),
net_if_stats=lambda: stats,
net_io_counters=lambda **kw: dict.fromkeys(names, counter) if kw else counter),
'_ensure_net_rate_sampler': lambda: None, '_compute_per_nic_rates_live': lambda _: {},
'_is_bond_master': lambda _: False, '_is_physical_dev': lambda name: name.startswith('eno'),
'_ethtool_max_speed': lambda _: 1000,
'_iface_master': lambda name: {'tap115i0': 'fwbr115i0', 'fwln115o0': 'ovs-system'}.get(name),
'extract_vmid_from_interface': lambda name: (103 if '103' in name else 115, 'vm'),
'get_bridge_info': lambda _: {'members': [], 'physical_interface': None,
'physical_duplex': 'unknown', 'bond_slaves': []}}
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'flask_server.py', 'exec'), ns)
return ns
def test_api_preserves_linux_bridge_and_assigns_ovs_guests_and_bond(self):
ns = self.api_namespace()
# OVS creates no fwpr peer for the firewall bridge.
with patch('os.path.exists', side_effect=lambda path: 'fwpr' not in str(path)):
result = ns['get_network_info']()
self.assertNotIn('error', result)
self.assertEqual({i['name'] for i in result['bridge_interfaces']}, {'switch0', 'vmbr1'})
self.assertEqual(result['bond_interfaces'][0]['name'], 'uplink')
self.assertEqual(result['bond_interfaces'][0]['speed'], 0)
self.assertIsNone(result['bond_interfaces'][0]['mac_address'])
self.assertIn('uplink', {i['name'] for i in result['interfaces']})
for guest in result['vm_lxc_interfaces']:
self.assertEqual(guest['bridge_owner'], 'switch0')
self.assertEqual(next(i for i in result['interfaces'] if i['name'] == 'vlan100')['ovs_tag'], 100)
for nic in result['physical_interfaces']:
self.assertIn('switch0', nic['used_by_bridges'])
def test_packaged_module_is_shipped_with_backend(self):
self.assertIn('cp "$SCRIPT_DIR/ovs_topology.py" "$APP_DIR/usr/bin/"',
(SCRIPTS / 'build_appimage.sh').read_text())
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
// Exercise both shipped SVG layouts with OVS internal ports and bond topology.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const source = fs.readFileSync(path.join(__dirname, '../components/network-flow.tsx'), 'utf8')
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
}).outputText
const sandbox = { exports: {}, require: () => ({}), console }
vm.runInNewContext(code, sandbox)
const data = {
nics: [
{ id: 'eno1', link: '1 Gb/s', rx: 1, tx: 2, status: 'up' },
{ id: 'eno2', link: '1 Gb/s', rx: 1, tx: 2, status: 'up', bond: 'uplink', role: 'member' },
{ id: 'eno3', link: '1 Gb/s', rx: 1, tx: 2, status: 'up', bond: 'uplink', role: 'member' },
],
bonds: [{ id: 'uplink', mode: 'balance-tcp', rx: 2, tx: 4, status: 'up' }],
bridges: [{ id: 'switch0', parent: 'eno1' }, { id: 'vmbr1' }],
consumers: [
{ id: 'host', kind: 'host', label: 'host', bridge: 'switch0', rx: 1, tx: 2 },
{ id: 'vlan100', kind: 'interface', label: 'vlan100 · VLAN 100', bridge: 'switch0', rx: 0, tx: 0 },
{ id: 'tap103i0', kind: 'vm', label: 'VM 103', bridge: 'switch0', rx: 1, tx: 2 },
{ id: 'veth115i0', kind: 'lxc', label: 'LXC 115', bridge: 'switch0', rx: 1, tx: 2 },
{ id: 'tap104i0', kind: 'vm', label: 'VM 104', bridge: 'vmbr1', rx: 1, tx: 2 },
],
}
const labels = { down: 'Down', active: 'Active', standby: 'Standby' }
for (const render of [sandbox.renderHorizontal, sandbox.renderVertical]) {
const result = render(data, 1500, labels, 'Bytes')
for (const id of ['switch0', 'vmbr1', 'vlan100', 'tap103i0', 'veth115i0', 'tap104i0', 'uplink']) {
assert.ok(result.svg.includes(`data-node-id="${id}"`), `layout must draw ${id}`)
}
assert.ok(result.svg.includes('vlan100 · VLAN 100'))
assert.ok(!result.svg.includes('NaN'))
assert.ok(!result.svg.includes('undefined'))
}
console.log('PASS: both Network Flow layouts render OVS bonds, internal VLAN ports and mixed Linux/OVS guests')
+37
View File
@@ -0,0 +1,37 @@
# Native Monitor AppImage builds
The builder selects its native architecture using `uname -m`. Supported targets
are x86_64 and aarch64 (ARM64). It selects matching appimagetool executables,
separate download caches, manylinux wheels and GNU library paths. Cross-building
by overriding a target variable is intentionally unsupported: APT packages and
other native Python dependencies must also match the build machine.
Run `bash AppImage/scripts/build_appimage.sh` on a Linux builder with Node.js,
Python/pip, APT and the usual AppImage build dependencies. AMD64 keeps the existing
`ProxMenux-VERSION.AppImage` name; ARM64 produces
`ProxMenux-VERSION-aarch64.AppImage`.
The native validation workflow builds both architectures on GitHub's native
Ubuntu 22.04 runners, extracts each AppImage without FUSE, and imports its bundled
CPython 3.11 and 3.13 WebSocket/image runtimes, including the extracted launcher
with only its final server execution intercepted. It does not upload artifacts,
change release assets, or modify an installed Monitor.
This build lane is a prerequisite for ARM distribution, not a declaration of
PXVIRT or Raspberry Pi hardware compatibility. ARM release/checksum publication,
installer selection and validation on the maintainer's PXVIRT lab remain separate
steps before ARM installations can use a published image. The current release
and installer distribution remain unchanged.
## Borg host backups on ARM64
Borg 1.2.8 publishes an AMD64 standalone executable but no ARM64 equivalent.
ARM64 builds therefore include a small adapter that uses `/usr/bin/borg` from
the host's native `borgbackup` package. Without that package it exits with a
clear error; it never runs the AMD64 binary. The host-backup helper likewise
refuses AMD64 cached/downloaded binaries on non-AMD64 hosts. The existing AMD64
standalone binary and checksum remain unchanged. Operators must install their
native distro package separately; this build does not install it on the host.
ARM64 uses the official versioned appimagetool 1.9.1 with SHA256 verification;
the existing AMD64 packaging tool remains unchanged.
+216
View File
@@ -0,0 +1,216 @@
# USB/IP over SSH
Open **Hardware: GPUs and Coral-TPU → USB/IP over SSH** to share a
selected USB device or connect this Linux machine to a device on another host.
The same menu is available through the main menu search (`usbip`, `zigbee`,
`bluetooth` or `printer`). This implements the host and Linux client setup
requested in [#350](https://github.com/MacRimi/ProxMenux/issues/350).
## Server
1. Enable the USB/IP server. Missing `usbip`, `socat`, `kmod` and `openssh-client`
packages are offered from the existing Debian repositories. Repository and
SSH server settings are preserved.
2. Choose **Share a local USB device** and select a device by its bus ID, product
and serial number. Confirm that it will become unavailable to local programs
and guests using ordinary USB passthrough.
3. Check **View USB/IP status**. The device should show `exported` and a bus ID.
Binding errors are reported separately rather than treated as a successful
export. Inspect the service log for startup errors:
```sh
journalctl -u proxmenux-usbip-server.service
```
The server reloads saved selections every three seconds. It keeps open usbfs
references to each original USB device and its USB ancestors before binding.
After binding it marks the live match REMOV and verifies the original is still
bound. Replacement devices cannot use that match for automatic USB/IP probing.
On physical removal, the worker waits for the original device's completed kernel
remove event, uses the still-pinned original reference to clear that one retained
slot, and verifies disappearance before binding again. Other exports are left
running. Reconnection at the same port, movement to another port and normal
present-device release do not require reloading a shared module.
If the worker is killed, a bind cannot be verified, removal events are lost, or
an older installation left a retained slot, that slot has no proven lifetime
reference. The worker reports an error and refuses to detach a new local device
or attempt absent-slot recovery. Stop sharing while the original remains present;
if that cannot clear the slot, a host reboot is the supported recovery. This
exceptional recovery interrupts the host, so schedule it yourself; the manager
never reboots it or reloads USB modules automatically. Graceful service stop
releases managed bindings before dropping the references.
Without a serial number, the vendor/product and original bus ID must match;
the manager will not guess which identical dongle to bind. Duplicate serial
numbers are rejected as ambiguous. USB hubs and mass-storage devices, including
composite devices with a storage interface, are excluded. So are USB network
adapters: on many mini PCs one is the management link of the host.
**Stop sharing** removes one saved selection and waits for confirmed release.
A cleanup error or timeout is reported instead of claiming that eject succeeded.
**Disable server** stops the service and releases its
managed exports, retaining selections for the next enable. Devices already
bound by another USB/IP setup are not adopted or unbound by this manager.
Native USB/IP unbind reprobes the device so its normal local driver can return.
## Transport and authentication
USB/IP has no built-in authentication or encryption. The generated server unit
uses systemd `PrivateNetwork=yes`: `usbipd --ipv4 --tcp-port 3240` and its
Python protocol gate run in an isolated network namespace. There is **no USB/IP TCP
listener in the host network namespace**, and this feature does not open a
firewall port. Running the manager's `serve` command outside a private network
namespace is rejected before spawning a listener.
The gate exposes `/run/proxmenux-usbip-server/usbip.sock` to the dedicated
`proxmenux-usbip` group (0660), inside a root-owned 0750 directory. For each native
DEVLIST and IMPORT, the gate checks the saved selection,
verified ownership, current USB identity, driver and sysfs incarnation. It checks
again before releasing a successful import reply; an unselected export bound by
another setup cannot bypass selection by requesting its bus ID directly. Control
requests have bounded time, memory and connection counts. Accepted URB streams
retain full-duplex transfer, half-close and backpressure without an idle timeout.
Root alone can read or change the managed device/configuration state.
A separate group-readable
catalog contains only the selected USB device metadata.
Before enabling the server, choose **Authorize a client SSH public key** and
supply the path to a dedicated client's `.pub` file already copied to the server.
This creates a local system account `proxmenux-usbip` with a disabled password,
no administrative group membership, and a root-owned home and `authorized_keys`.
Each managed key uses OpenSSH `restrict` and a fixed forced command. The command
accepts only `catalog` (read selected devices) and `stream` (connect the one USB/IP
Unix socket). Shell commands, PTY, agent/X11 forwarding and TCP/Unix forwarding
are prohibited. This does not use or modify Proxmox's cluster-wide root keys,
root `authorized_keys`, or SSH server settings. A conflicting pre-existing
account/home is rejected rather than adopted. Authorize keys before starting
the server; `useradd` and an existing OpenSSH server are required.
The client holds its loopback TCP listening socket continuously, then launches
an SSH `stream` session for each connection. It does not release a port probe
and later ask SSH to bind the same port. Unknown or changed host keys and wrong
client keys fail closed. Create a dedicated key, not one used for root access.
Anyone with that key can access **all selected USB/IP exports**; it is not a
per-device authorization policy. Local users of the client can also connect to
the loopback port, so use trusted clients. To revoke a client, remove its managed
line from `/var/lib/proxmenux-usbip/.ssh/authorized_keys` and terminate its active
connections; deleting a key does not disconnect an existing SSH session.
Old configurations using root keys do not silently keep root access: clients
now connect only as `proxmenux-usbip`. Authorize a separate key on the server and
replace the saved client connection if it previously referenced a root key.
## Linux VM/client
Run the client **inside the Linux VM that needs the device**. Connecting it to
a Proxmox node alone does not make that device migrate with a VM. A supported
Linux kernel must provide `vhci-hcd`; the server needs `usbip-host`. Proxmox
kernel packages normally provide these modules, but activation fails visibly
if they are missing. No out-of-tree kernel modules are installed.
The client can use the menu when ProxMenux is installed, or the standalone
standard-library Python manager. On a Debian Linux guest, install the packages
and copy `scripts/devices/usbip_manager.py` from the reviewed ProxMenux checkout:
```sh
apt-get install usbip openssh-client kmod
install -D -m 0755 usbip_manager.py /usr/local/share/proxmenux/scripts/devices/usbip_manager.py
```
Generate a dedicated SSH client key (for example `/root/.ssh/id_usbip`) and
authorize its public key through the server menu. Verify the server host-key
fingerprint through a trusted channel and install it in the client's root
`known_hosts` file. Do not attempt an interactive login: the account intentionally
rejects a shell. SSH config aliases and `ProxyJump` settings are not used; enter
the actual address and port.
Then choose **Connect a remote USB device** and provide the server address,
SSH port and absolute path to an existing private key (0400 or 0600). The menu
authenticates and lists the server's configured exports before saving a client.
Choose an unused local port for each device (33240 by default).
Equivalent standalone commands:
```sh
MANAGER=/usr/local/share/proxmenux/scripts/devices/usbip_manager.py
python3 "$MANAGER" remote --host 192.0.2.10 --ssh-port 22 --key /root/.ssh/id_usbip
# Substitute the export ID returned by the server:
python3 "$MANAGER" add-import --host 192.0.2.10 --ssh-port 22 --key /root/.ssh/id_usbip --export-id abcdef123456 --local-port 33240
python3 "$MANAGER" status
```
The client has an enabled systemd service per device. It reconnects the SSH
tunnel and reattaches the device after a network interruption or restart. On
each new attach it resolves the **saved export ID** to the server's current bus
ID; physical port movement is resolved only after confirmed cleanup of the old
slot. The client keeps an ownership
record of the exact imported kernel port, local bus ID and remote endpoint;
cleanup detaches only a matching owned import. Removing a connection disables
its service, releases its device and removes its saved configuration.
Only **one client at a time** can attach a physical USB device. A device already
in use by another client may be listed as exported, but the kernel rejects a
second attach; the client reports the error and waits. Applications must handle
USB disconnect/reconnect themselves. A brief network interruption can still
reset the USB device and interrupt a Zigbee coordinator, printer or Bluetooth
session; this is not transparent USB high availability.
Unprivileged LXC, Home Assistant OS and non-Linux clients require their own
kernel/device permissions or add-on integration. This feature does not change
LXC privileges, edit VM passthrough settings or deploy HAOS add-ons. A standard
addon that expects raw USB/IP TCP also needs the SSH transport configured; it
cannot directly connect to this server's isolated daemon.
## Files and checks
- Root-only configuration: `/etc/proxmenux/usbip/config.json` (0600).
- Owned server unit: `proxmenux-usbip-server.service`.
- Owned client units: `proxmenux-usbip-client-<id>.service`.
- Server ownership/status: `/run/proxmenux-usbip-server/`.
- Client ownership/status: `/run/proxmenux-usbip/`, independent of server cleanup.
- Menu actions and package installations use the ProxMenux journal when present.
Validation includes fake sysfs/USB commands for identity and ownership, service
rollback and quoting, malformed catalogs, credential failures and reconnects.
The Linux CI also verifies generated units and exercises actual namespace
isolation and restricted SSH sessions with native USB/IP control messages.
Protocol socket tests cover binary transfers, malformed requests, selection
changes, foreign exports and replaced device objects. Lifetime tests require a
verified pin and a matching completed kernel removal event before slot recovery.
The kernel regression lab is deliberately opt-in and requires a fresh, disposable
QEMU Linux VM with usbip, socat and the dummy_hcd/libcomposite/usbip-host/vhci-hcd
modules. Never run it on a production host. In that VM, from the checkout:
```sh
sudo unshare --net sh -c 'ip link set lo up; python3 -B .github/scripts/check_usbip_kernel.py --disposable-vm'
```
It creates two virtual ACM gadgets, performs 20 physical unplug/replug cycles,
rejects an unselected same-port replacement, reimports the original and confirms
local-driver restoration. An independent native USB/IP serial transfer must keep
working during the cycles. The lab passed on Debian 13 / Linux 6.12.115; actual
hardware and guest-driver compatibility still depends on the deployment.
The companion service lab must run in a separate fresh disposable QEMU VM:
```sh
sudo python3 -B .github/scripts/check_usbip_services.py --disposable-vm
```
It installs the actual generated server/client units and a dedicated SSH key
inside that VM, transfers native serial data in both directions through restricted
SSH, checks three automatic reconnects with an independent connection alive, and
verifies synchronous stop-sharing and disable cleanup. Destroy the VM afterwards.
The lifetime contract is based on [usbfs open/release references](https://github.com/torvalds/linux/blob/v6.12/drivers/usb/core/devio.c),
[USB/IP match/rebind](https://github.com/torvalds/linux/blob/v6.12/drivers/usb/usbip/stub_main.c)
and [completed stub disconnect](https://github.com/torvalds/linux/blob/v6.12/drivers/usb/usbip/stub_dev.c).
A sysfs pathname alone is never sufficient authority for absent-slot rebind.
Protocol and transport references:
[Linux USB/IP tools](https://github.com/torvalds/linux/tree/master/tools/usb/usbip),
[Debian usbip manual](https://manpages.debian.org/trixie/usbip/usbip.8.en.html),
[OpenSSH authorized_keys restrictions](https://man.openbsd.org/sshd#AUTHORIZED_KEYS_FILE_FORMAT),
[systemd PrivateNetwork](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#PrivateNetwork=).
+41
View File
@@ -246,6 +246,7 @@
"Aborted": "Abgebrochen",
"About to restore": "Wird gerade wiederhergestellt",
"Absolute directory path to use as backup target:": "Absoluter Verzeichnispfad zur Verwendung als Sicherungsziel:",
"Absolute path of the client SSH private key:": "Absoluter Pfad des privaten SSH-Schlüssels des Clients:",
"Absolute path to a file or directory you want backed up:": "Absoluter Pfad zu einer Datei oder einem Verzeichnis, die/das Sie sichern möchten:",
"Acceleration": "Beschleunigung",
"Acceleration configuration cancelled": "Beschleunigungskonfiguration abgesagt",
@@ -619,6 +620,8 @@
"Authentication:": "Authentifizierung:",
"Authorization failed": "Die Autorisierung ist fehlgeschlagen",
"Authorization successful": "Autorisierung erfolgreich",
"Authorize a client SSH public key": "Öffentlichen SSH-Schlüssel eines Clients autorisieren",
"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 this key on the server": "Autorisieren Sie diesen Schlüssel auf dem Server",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Autorisieren über „pct exec“ (Borg-Server ist ein LXC auf einem PVE-Host, in den ich SSH einbinden kann)",
"Authorized": "Autorisiert",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Widersprüchliche Dienstprogramme entfernt",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Verbinde Zigbee-Geräte mit MQTT über eine webbasierte Einrichtungsoberfläche.",
"Connect a Coral Accelerator and try again.": "Schließen Sie einen Coral Accelerator an und versuchen Sie es erneut.",
"Connect a remote USB device": "Entferntes USB-Gerät verbinden",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Verbinden Sie es oder entfernen Sie es aus dem Container in Proxmox (Ressourcen) und führen Sie die Wiederherstellung erneut aus.",
"Connect your devices and users together in your own secure virtual private network.": "Verbinden Sie Ihre Geräte und Benutzer in Ihrem eigenen sicheren virtuellen privaten Netzwerk.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Verbinden Sie Ihre Geräte mit einem sicheren WireGuard®-basierten Overlay-Netzwerk mit SSO, MFA und granularen Zugriffskontrollen.",
@@ -1961,12 +1965,14 @@
"Directus administrator": "Directus-Administrator",
"Disable NIC Offloading (Intel e1000e)": "NIC-Offloading deaktivieren (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Deaktivieren Sie Secure Boot in der UEFI-Firmware und führen Sie das Installationsprogramm erneut aus. Es wurde nichts geändert.",
"Disable USB/IP server": "USB/IP-Server deaktivieren",
"Disable autostart on conflicting VM": "Deaktivieren Sie den Autostart auf in Konflikt stehenden VMs",
"Disable autostart on one VM to avoid startup conflicts.": "Deaktivieren Sie den Autostart auf einer VM, um Startkonflikte zu vermeiden.",
"Disable job": "Job deaktivieren",
"Disable kernel audit messages (optional but recommended):": "Deaktivieren Sie Kernel-Überwachungsmeldungen (optional, aber empfohlen):",
"Disable onboot on affected VM(s)/CT(s)": "Onboot auf betroffenen VM(s)/CT(s) deaktivieren",
"Disable portmapper/rpcbind": "Portmapper/rpcbind deaktivieren",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Den Server deaktivieren und alle von ProxMenux verwalteten USB-Geräte freigeben? Die gespeicherte Auswahl bleibt für die nächste Aktivierung erhalten.",
"Disabled": "Deaktiviert",
"Disabling High Availability services...": "Hochverfügbarkeitsdienste werden deaktiviert...",
"Disabling ZFS autotrim applied by ProxMenux...": "Deaktivieren der von ProxMenux angewendeten ZFS-Autotrim-Funktion ...",
@@ -2199,6 +2205,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Aktivieren Sie Remote Desktop (RDP), bevor Sie die virtuelle Anzeige deaktivieren.",
"Enable SSD emulation for this disk?": "SSD-Emulation für diese Festplatte aktivieren?",
"Enable TCP BBR/Fast Open control": "Aktivieren Sie die TCP-BBR/Fast-Open-Steuerung",
"Enable USB/IP server": "USB/IP-Server aktivieren",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Aktivieren von VA-API Transcoding und HDR10/Dolby Vision to SDR Tone Mapping",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Aktivieren Sie VA-API, Hardware-Codierung und OpenCL-Tone-Mapping in Jellyfin",
"Enable VFIO IOMMU support": "VFIO IOMMU-Unterstützung aktivieren",
@@ -3212,6 +3219,7 @@
"If pvesm path returned a DEVICE (LVM):": "Wenn pvesm path ein GERÄT (LVM) zurückgegeben hat:",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Wenn pvesm path ein VERZEICHNIS (ZFS/BTRFS) zurückgegeben hat:",
"If repositories error:": "Bei Repository-Fehler:",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Wenn der USB/IP-Dienst unerwartet stoppt, beenden Sie die Freigabe, solange das Gerät noch angeschlossen ist. In seltenen Fällen ist es erst nach einem Neustart des Hosts wieder verfügbar.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Wenn der Container bereits geändert wurde, wird sein vorheriges natives Backup wiederhergestellt. Freigegebene Hostverzeichnisse werden nicht zurückgesetzt. Die Festplatten des fehlgeschlagenen Versuchs werden entfernt, wenn die Bereinigung erfolgreich ist.",
"If the host freezes, remove hostpci entries from": "Wenn der Host einfriert, entfernen Sie die Hostpci-Einträge aus",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If anything fails, the backup is restored.": "Wenn der Image-Kanal eine neue Version hat, wird CT gestoppt, gesichert und überprüft und durch das neue Image ersetzt, um den Container zu aktualisieren. Wenn etwas fehlschlägt, wird das Backup wiederhergestellt.",
@@ -3406,6 +3414,7 @@
"Install server packages inside CT:": "Installieren Sie Serverpakete in CT:",
"Install terminal multiplexers": "Installieren Sie Terminal-Multiplexer",
"Install the Edge TPU runtime (libedgetpu1-std)": "Installieren Sie die Edge TPU-Laufzeit (libedgetpu1-std).",
"Install the USB/IP dependencies from the configured Debian repositories?": "USB/IP-Abhängigkeiten aus den konfigurierten Debian-Repositories installieren?",
"Install this image?": "Installieren Sie dieses Bild?",
"Install with Cloud-Init script": "Installation mit Cloud-Init-Skript",
"Install with ISO from UUP Dump": "Mit ISO von UUP Dump installieren",
@@ -3596,6 +3605,7 @@
"Invalid check package:": "Ungültiges Kontrollpaket:",
"Invalid configuration path:": "Ungültiger Konfigurationspfad:",
"Invalid consume/export volumes": "Ungültige Verbrauchs-/Ausfuhrmengen",
"Invalid container device GID": "Ungültige Geräte-GID im Container",
"Invalid container path:": "Ungültiger Containerpfad:",
"Invalid credential file path:": "Ungültiger credential-Dateipfad:",
"Invalid declarative entrypoint": "Ungültiger deklarativer Eingangspunkt",
@@ -3890,9 +3900,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Einzufügende Zeile (einzelne Zeile, einschließlich „command=…“-Präfix):",
"LinkStack": "LinkStack",
"Linux Installation Options": "Linux-Installationsoptionen",
"Linux guest setup and migration notes": "Hinweise zur Einrichtung und Migration von Linux-Gästen",
"Linux/Mac path:": "Linux/Mac-Pfad:",
"LinuxServer Jellyfin with optional GPU passthrough": "LinuxServer Jellyfin mit optionalem GPU-Passthrough",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB requires ein Benutzer, Datenbank und Passwort",
"LinuxServer Nextcloud with MariaDB and Redis": "LinuxServer Nextcloud mit MariaDB und Redis",
"List Available Disks": "Verfügbare Festplatten auflisten",
"List IOMMU group mapping": "IOMMU-Gruppenzuordnung auflisten",
"List NVMe devices": "NVMe-Geräte auflisten",
@@ -4397,6 +4409,7 @@
"Name or part of the option or of the application": "Name oder Teil der Option oder der Anwendung",
"Name:": "Name:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Benannte Konten benötigen einen privaten Modus. Stoppen Sie den Container, setzen Sie public: false in /config/config.js, starten Sie ihn erneut und erstellen Sie jeden Benutzer mit: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "Natives Borg ist nicht verfügbar. Installieren Sie das native Paket borgbackup auf diesem Host, um Borg-Backups zu verwenden.",
"Needed:": "Erforderlich:",
"Needs": "Benötigt",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Weder /etc/kernel/cmdline noch /etc/default/grub gefunden.",
@@ -4472,6 +4485,7 @@
"Next Step Required": "Nächster Schritt erforderlich",
"Next Steps:": "Nächste Schritte:",
"Next step: stop that VM first, then run": "Nächster Schritt: Stoppen Sie zuerst die VM und führen Sie sie dann aus",
"Nextcloud Web UI": "Nextcloud-Weboberfläche",
"Nextcloud configuration cancelled": "Nextcloud-Konfiguration gestrichen",
"Nextcloud gives you access to all your files wherever you are.": "Nextcloud bietet Ihnen Zugriff auf alle Ihre Dateien, wo immer Sie sind.",
"Nextcloud volume size in GB": "Nextcloud Volumengröße in GB",
@@ -4646,6 +4660,7 @@
"No duplicate IP addresses": "Keine doppelten IP-Adressen",
"No duplicate repositories found": "Keine doppelten Repositorys gefunden",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Nach der SR-IOV-Filterung bleiben keine berechtigten Controller/NVMe-Geräte übrig. Überspringen.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Keine geeigneten USB-Geräte gefunden. Hubs, Massenspeicher und Netzwerkadapter sind ausgeschlossen.",
"No eligible controllers remain after SR-IOV filtering.": "Nach der SR-IOV-Filterung bleiben keine berechtigten Controller übrig.",
"No encryption key is stored on this host. Choose how to set one up:": "Auf diesem Host ist kein Verschlüsselungsschlüssel gespeichert. Wählen Sie aus, wie Sie eines einrichten möchten:",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Es wurden keine exportierbaren VM-Festplatten gefunden (CD-ROM/Cloud-Init sind ausgeschlossen).",
@@ -4730,6 +4745,7 @@
"No self-test log available for": "Kein Selbsttestprotokoll verfügbar für",
"No server IP or hostname provided.": "Keine Server-IP oder Hostname angegeben.",
"No share access test succeeded with guest authentication.": "Bei der Gastauthentifizierung war kein Freigabezugriffstest erfolgreich.",
"No shared devices are available on this server.": "Auf diesem Server sind keine freigegebenen Geräte verfügbar.",
"No shared media content.": "Keine geteilten Medieninhalte.",
"No shared mount detected. Applying standard local access.": "Kein gemeinsamer Mount erkannt. Anwenden des standardmäßigen lokalen Zugriffs.",
"No shares configured.": "Keine Freigaben konfiguriert.",
@@ -5128,6 +5144,7 @@
"Path of the Compose file": "Pfad der Compose-Datei",
"Path of the oci-compose file": "Pfad der oci-compose-Datei",
"Path removed:": "Pfad entfernt:",
"Path to the client SSH public key on this server:": "Pfad zum öffentlichen SSH-Schlüssel des Clients auf diesem Server:",
"Path:": "Weg:",
"Paths applied:": "Angewandte Pfade:",
"Paths included in backup": "Im Backup enthaltene Pfade",
@@ -5370,6 +5387,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux installiert sie nicht. Die Installation fragt, wie man sich mit ihnen verbindet.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux validiert den Inhalt nicht;Jede von Ihrem PBS akzeptierte Schlüsseldatei wird hier akzeptiert.",
"ProxMenux files:": "ProxMenux-Dateien:",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "ProxMenux erzeugt das Administratorpasswort und zeigt es am Ende der Installation an. Ändern Sie es nach der ersten Anmeldung.",
"ProxMenux logo applied": "ProxMenux-Logo angewendet",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux fungiert nur als Startprogramm – sobald das Skript gestartet wird, verlässt ProxMenux die Steuerung.",
"ProxMenux saved it locally at:": "ProxMenux hat es lokal gespeichert unter:",
@@ -5590,6 +5608,7 @@
"Recreation prepared": "Erholung vorbereitet",
"Recursive DNS resolver.": "Rekursiver DNS-Resolver.",
"Recursive and authoritative DNS server.": "Rekursiver und autoritativer DNS-Server.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "Die Dateisperre mit Redis wird automatisch eingerichtet. Vorhandene Nextcloud-Konfigurationsdateien bleiben erhalten.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "APT-Index aktualisieren und Repositorys überprüfen:",
"Refresh catalog": "Katalog aktualisieren",
@@ -5673,6 +5692,7 @@
"Remove Proxmox NFS storage:": "Entfernen Sie den Proxmox NFS-Speicher:",
"Remove Proxmox iSCSI storage:": "Entfernen Sie den Proxmox iSCSI-Speicher:",
"Remove Secure Gateway? State will be preserved.": "Secure Gateway entfernen? Der Zustand bleibt erhalten.",
"Remove a remote USB connection": "Entfernte USB-Verbindung entfernen",
"Remove custom paths": "Benutzerdefinierte Pfade entfernen",
"Remove disk references from affected VM(s)/CT(s) config": "Entfernen Sie Festplattenverweise aus der Konfiguration der betroffenen VM(s)/CT(s).",
"Remove every registered gasket DKMS version": "Alle registrierten gasket-DKMS-Versionen entfernen",
@@ -5693,6 +5713,7 @@
"Remove the lock left on a container by an interrupted task": "Die Sperre entfernen, die eine unterbrochene Aufgabe auf einem Container hinterlassen hat",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Die gespeicherten Anmeldeinformationen für {registry} entfernen?\nLaufende Apps funktionieren weiterhin.",
"Remove the unprivileged flag from configuration:": "Entfernen Sie das Unprivileged-Flag aus der Konfiguration:",
"Remove this connection and detach its USB device?": "Diese Verbindung entfernen und das USB-Gerät trennen?",
"Remove unused packages and their config": "Entfernen Sie nicht verwendete Pakete und deren Konfiguration",
"Remove: delete the application and its containers": "Entfernen: Löschen Sie die Anwendung und ihre Container",
"Removed": "ENTFERNT",
@@ -5915,9 +5936,11 @@
"Run mode: Unattended": "Betriebsmodus: Unbeaufsichtigt",
"Run now": "Jetzt ausführen",
"Run security audit now": "Führen Sie jetzt ein Sicherheitsaudit durch",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Führen Sie den Client in der Linux-VM aus, die das USB-Gerät benötigt. Installieren Sie usbip, openssh-client und kmod und kopieren Sie usbip_manager.py an denselben Pfad wie auf dem Server. Richten Sie die Schlüsselauthentifizierung ein und prüfen Sie den Hostschlüssel des Servers, bevor Sie eine Verbindung hinzufügen. Der Client verwendet den Gastkernel und kann sich nach einer Migration erneut verbinden, während das physische USB-Gerät am Server bleibt. Unprivilegierte LXC und Home Assistant OS benötigen eine eigene Kernel-/Geräte- oder Add-on-Einrichtung. Ein physisches Gerät kann jeweils nur mit einem Client verbunden sein.",
"Run the following inside the VM:": "Führen Sie Folgendes in der VM aus:",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Führen Sie den Release-Channel-Schalter von einer SSH-Sitzung oder der Proxmox-Hostkonsole aus mit:",
"Run the update from an SSH session or the Proxmox host console with:": "Führen Sie das Update über eine SSH-Sitzung oder die Proxmox-Hostkonsole aus mit:",
"Run this menu as root.": "Führen Sie dieses Menü als root aus.",
"Run this operation as root.": "Führen Sie diesen Vorgang als root aus.",
"Run upgrade checklist script:": "Führen Sie das Upgrade-Checklisten-Skript aus:",
"Running": "Läuft",
@@ -6142,6 +6165,7 @@
"Select a new language for the menu:": "Wählen Sie eine neue Sprache für das Menü:",
"Select a post-installation script:": "Wählen Sie ein Nachinstallationsskript aus:",
"Select a pre-configured Linux VM script to execute:": "Wählen Sie ein vorkonfiguriertes Linux-VM-Skript zur Ausführung aus:",
"Select a saved USB/IP device:": "Gespeichertes USB/IP-Gerät auswählen:",
"Select a script or action:": "Wählen Sie ein Skript oder eine Aktion aus:",
"Select a share to delete:": "Wählen Sie eine Freigabe zum Löschen aus:",
"Select a specific Coral or USB node, not the whole /dev": "Wählen Sie einen bestimmten Coral- oder USB-Knoten, nicht den gesamten /dev",
@@ -6219,6 +6243,7 @@
"Select the Linux distribution to install:": "Wählen Sie die zu installierende Linux-Distribution aus:",
"Select the NAS system to install:": "Wählen Sie das zu installierende NAS-System aus:",
"Select the NVIDIA driver version to install:": "Wählen Sie die zu installierende NVIDIA-Treiberversion aus:",
"Select the USB device to share:": "Freizugebendes USB-Gerät auswählen:",
"Select the VM to add the GPU to:": "Wählen Sie die VM aus, zu der Sie die GPU hinzufügen möchten:",
"Select the VM to which you want to add disks:": "Wählen Sie die VM aus, zu der Sie Festplatten hinzufügen möchten:",
"Select the VM where you want to import the disk image:": "Wählen Sie die VM aus, in die Sie das Disk-Image importieren möchten:",
@@ -6243,6 +6268,7 @@
"Select the parent job to attach to:": "Wählen Sie den übergeordneten Job aus, an den Sie anhängen möchten:",
"Select the paths to restore (one per backup entry):": "Wählen Sie die wiederherzustellenden Pfade aus (einen pro Sicherungseintrag):",
"Select the privileged LXC container to convert:": "Wählen Sie den zu konvertierenden privilegierten LXC-Container aus:",
"Select the remote USB device:": "Entferntes USB-Gerät auswählen:",
"Select the storage that will receive the backups:": "Wählen Sie den Speicher aus, der die Backups erhalten soll:",
"Select the storage volume for disk import:": "Wählen Sie das Speichervolume für den Festplattenimport aus:",
"Select the sync interval (in hours):": "Wählen Sie das Synchronisierungsintervall (in Stunden):",
@@ -6329,6 +6355,7 @@
"Server IDs to test against, separated by commas (optional)": "Server-IDs zum Testen, durch Kommas getrennt (optional)",
"Server IP/hostname is correct": "Server-IP/Hostname ist korrekt",
"Server IP:": "Server-IP:",
"Server SSH port:": "SSH-Port des Servers:",
"Server is accessible:": "Server ist erreichbar:",
"Server is online": "Server ist online",
"Server is running": "Server läuft",
@@ -6390,6 +6417,7 @@
"Share Access Error": "Fehler beim Freigabezugriff",
"Share Name": "Teilen Sie den Namen",
"Share Options": "Freigabeoptionen",
"Share a local USB device": "Lokales USB-Gerät freigeben",
"Share added successfully.": "Teilen erfolgreich hinzugefügt.",
"Share deleted and Samba service restarted.": "Freigabe gelöscht und Samba-Dienst neu gestartet.",
"Share exists:": "Freigabe existiert:",
@@ -6397,6 +6425,7 @@
"Share name:": "Freigabename:",
"Share not found on server:": "Freigabe nicht auf Server gefunden:",
"Share path:": "Freigabepfad:",
"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 updated successfully.": "Freigabe erfolgreich aktualisiert.",
"Share:": "Aktie:",
"ShareCoveX panel": "ShareCoveX-Panel",
@@ -6665,6 +6694,8 @@
"Still has issues": "Es gibt immer noch Probleme",
"Stop - shuts the guest down during backup": "Stopp – fährt den Gast während der Sicherung herunter",
"Stop it first and run this option again.": "Stoppen Sie es zuerst und führen Sie diese Option erneut aus.",
"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 that VM, then choose:": "Stoppen Sie VM und wählen Sie dann:",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Stoppen Sie den CT, unmounten Sie die Festplatte auf dem HOST und mounten Sie sie erneut mit:",
"Stop the VM/CT before formatting this disk.": "Stoppen Sie die VM/CT, bevor Sie diese Festplatte formatieren.",
@@ -7020,6 +7051,7 @@
"The container could not be removed automatically:": "Der Container konnte nicht automatisch entfernt werden:",
"The container could not be started:": "Der Container konnte nicht gestartet werden:",
"The container creation does not match the prepared instance": "Die Containererstellung stimmt nicht mit der vorbereiteten Instanz überein",
"The container device settings changed since the last recorded operation": "Die Geräteeinstellungen im Container haben sich seit dem letzten protokollierten Vorgang geändert",
"The container devices do not match the saved record": "Die Containergeräte stimmen nicht mit dem gespeicherten Datensatz überein",
"The container did not stop to update its persistent configuration:": "Der Container hat nicht angehalten, um seine persistente Konfiguration zu aktualisieren:",
"The container did not stop; its disks are not touched": "Der Container hat nicht angehalten; seine Scheiben werden nicht berührt",
@@ -7301,6 +7333,7 @@
"The record no longer belongs to this operation": "Der Datensatz gehört nicht mehr zu dieser operation",
"The record of a member was replaced; the assembly is not resumed": "Die Aufzeichnung eines Mitglieds wurde ersetzt; die Versammlung wird nicht wieder aufgenommen",
"The record or diagnosis could not be completed; no update was run.": "Die Aufzeichnung oder Diagnose konnte nicht abgeschlossen werden; kein Update wurde ausgeführt.",
"The recorded container device GID does not match its configuration": "Die protokollierte Geräte-GID im Container stimmt nicht mit der Konfiguration überein",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "Die Rückforderung war nicht abgeschlossen. Überprüfen Sie das Protokoll und wählen Sie erneut \"Wiederherstellen\" für diesen Container im OCI-Verwaltungsmenü.",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "Die Wiederherstellung wurde nicht abgeschlossen. Prüfen Sie das Protokoll und wählen Sie für diesen Container im OCI-Verwaltungsmenü erneut \"{choice}\".",
"The registry authentication file is invalid; it was not changed.": "Die Registrierungsauthentifizierungsdatei ist ungültig; es wurde nicht geändert.",
@@ -7368,6 +7401,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "Die ausgewählte Intel-GPU verfügt über Nicht-FLR-Reset-Unterstützung und einen unbekannten Subtyp",
"The selected Intel GPU is currently in power state D3cold": "Die ausgewählte Intel-GPU befindet sich derzeit im Energiezustand D3cold",
"The selected Intel render device does not exist:": "Das ausgewählte Intel-Rendergerät existiert nicht:",
"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 VM": "Die ausgewählte VM",
"The selected VM is running.": "Die ausgewählte VM läuft.",
"The selected base folder does not exist and could not be created:": "Der ausgewählte Basisordner existiert nicht und konnte nicht erstellt werden:",
@@ -7707,6 +7741,9 @@
"USB drives mounted now:": "Jetzt gemountete USB-Laufwerke:",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "USB- oder serieller Geräteknoten",
"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:",
"UUP Dump script not found.": "UUP-Dump-Skript nicht gefunden.",
"UUp Dump ISO creator Custom": "UUp Dump ISO-Ersteller Benutzerdefiniert",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity ist ein kostenloser, leichter und benutzerfreundlicher Heimserver für Ihre Comics und E-Books. Verwenden Sie es, um von überall auf Ihre Dateien zuzugreifen, mit einem Tablet, einem E-Reader, einem Telefon oder einem Computer.",
@@ -7863,6 +7900,7 @@
"Untrusted or modified NVIDIA hook": "Nicht vertrauenswürdiger oder modifizierter NVIDIA-Hook",
"Unused image removed from the cache:": "Nicht verwendetes Bild aus dem Cache entfernt:",
"Unused images removed from the cache:": "Nicht verwendete Bilder aus dem Cache entfernt:",
"Unused local loopback TCP port for this device:": "Freier lokaler Loopback-TCP-Port für dieses Gerät:",
"Update": "Aktualisierung",
"Update Available": "Update verfügbar",
"Update Ceph repository (Only if using Ceph):": "Ceph-Repository aktualisieren (nur bei Verwendung von Ceph):",
@@ -7961,6 +7999,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Verwenden Sie einen privilegierten LXC für NFS-Server-/Client-Workflows.",
"Use a privileged LXC for Samba client/server workflows.": "Verwenden Sie einen privilegierten LXC für Samba-Client/Server-Workflows.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Verwenden Sie einen Registrierungs-Hostnamen, zum Beispiel ghcr.io oder registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Verwenden Sie einen HTTPS-Reverse-Proxy mit einem vertrauenswürdigen Zertifikat. Aktualisieren Sie Nextcloud jeweils auf die nächste Hauptversion, ohne eine zu überspringen.",
"Use an existing SSH private key file on this host": "Verwenden Sie eine vorhandene private SSH-Schlüsseldatei auf diesem Host",
"Use an existing keyfile": "Verwenden Sie eine vorhandene Schlüsseldatei",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Verwenden Sie eine Bildreferenz, zum Beispiel ghcr.io/owner/app:latest.",
@@ -7974,6 +8013,7 @@
"Use existing filesystem": "Vorhandenes Dateisystem verwenden",
"Use existing: mount without formatting": "Vorhandenes verwenden: mounten ohne Formatierung",
"Use images only if the directory is on suitable storage.": "Verwenden Sie Bilder nur, wenn sich das Verzeichnis auf einem geeigneten Speicher befindet.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Verwenden Sie verwalteten Speicher für /config und MariaDB. Für /data können Sie ein Verzeichnis auf dem Host/NAS auswählen, das eine eigene Sicherung benötigt.",
"Use mp1/mp2/... for extra mount points.": "Verwenden Sie mp1/mp2/... für zusätzliche Mount-Punkte.",
"Use only when unprivileged containers cannot meet your needs": "Verwenden Sie es nur, wenn nicht privilegierte Container Ihre Anforderungen nicht erfüllen können",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Verwenden Sie Option 1, um eine lokale Festplatte hinzuzufügen (als Proxmox-Speicher und/oder als Host-Mount).",
@@ -8144,6 +8184,7 @@
"View Disk Storages": "Festplattenspeicher anzeigen",
"View Mount Points": "Mount-Punkte anzeigen",
"View NFS Mounts (pvesm + fstab)": "NFS-Mounts anzeigen (pvesm + fstab)",
"View USB/IP status": "USB/IP-Status anzeigen",
"View backup metadata": "Sicherungsmetadaten anzeigen",
"View current paths": "Aktuelle Pfade anzeigen",
"View existing folders in /mnt": "Vorhandene Ordner in /mnt anzeigen",
+41
View File
@@ -245,6 +245,7 @@
"Aborted": "Abortado",
"About to restore": "A punto de restaurar",
"Absolute directory path to use as backup target:": "Ruta absoluta del directorio para usar como destino del backup:",
"Absolute path of the client SSH private key:": "Ruta absoluta de la clave privada SSH del cliente:",
"Absolute path to a file or directory you want backed up:": "Ruta absoluta de un archivo o directorio del que desea hacer backup:",
"Acceleration": "Aceleración",
"Acceleration configuration cancelled": "Configuración de aceleración cancelada",
@@ -617,6 +618,8 @@
"Authentication:": "Autenticación:",
"Authorization failed": "Autorización fallida",
"Authorization successful": "Autorización correcta",
"Authorize a client SSH public key": "Autorizar una clave pública SSH de un cliente",
"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 this key on the server": "Autorizar esta clave en el servidor",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Autorizar a través de 'pct exec' (el servidor Borg es un LXC en un host PVE en el que puedo acceder mediante SSH)",
"Authorized": "Autorizado",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Se eliminaron las utilidades conflictivas",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Conecta dispositivos Zigbee a MQTT mediante una interfaz web de configuración.",
"Connect a Coral Accelerator and try again.": "Conecta un Coral Accelerator y vuelve a intentarlo.",
"Connect a remote USB device": "Conectar un dispositivo USB remoto",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Conéctalo, o elimínalo del contenedor en Proxmox (Recursos), y vuelve a lanzar la recuperación.",
"Connect your devices and users together in your own secure virtual private network.": "Conecta tus dispositivos y usuarios en tu propia red privada virtual segura.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Conecta tus dispositivos en una red overlay segura basada en WireGuard®, con SSO, MFA y controles de acceso granulares.",
@@ -1959,12 +1963,14 @@
"Directus administrator": "Administrador de Directus",
"Disable NIC Offloading (Intel e1000e)": "Desactivar offloading de NIC (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Desactiva Secure Boot en el firmware UEFI y vuelve a ejecutar el instalador. No se ha cambiado nada.",
"Disable USB/IP server": "Desactivar el servidor USB/IP",
"Disable autostart on conflicting VM": "Deshabilitar el inicio automático en VM en conflicto",
"Disable autostart on one VM to avoid startup conflicts.": "Desactiva el inicio automático en una VM para evitar conflictos de inicio.",
"Disable job": "Desactivar trabajo",
"Disable kernel audit messages (optional but recommended):": "Desactivar los mensajes de auditoría del kernel (opcional pero recomendado):",
"Disable onboot on affected VM(s)/CT(s)": "Deshabilitar el arranque en las VM/CT afectadas",
"Disable portmapper/rpcbind": "Desactivar portmapper/rpcbind",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "¿Desactivar el servidor y liberar todos los dispositivos USB gestionados por ProxMenux? La selección guardada se conserva para la próxima activación.",
"Disabled": "Desactivado",
"Disabling High Availability services...": "Deshabilitando los servicios de alta disponibilidad...",
"Disabling ZFS autotrim applied by ProxMenux...": "Deshabilitando el recorte automático ZFS aplicado por ProxMenux...",
@@ -2197,6 +2203,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Activa el Escritorio remoto (RDP) antes de desactivar la pantalla virtual.",
"Enable SSD emulation for this disk?": "¿Habilitar la emulación SSD para este disco?",
"Enable TCP BBR/Fast Open control": "Activar control TCP BBR/Fast Open",
"Enable USB/IP server": "Activar el servidor USB/IP",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Activar la transcodificación VA-API y el mapeo de tonos de HDR10/Dolby Vision a SDR",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Activar VA-API, la codificación por hardware y el mapeo de tonos OpenCL en Jellyfin",
"Enable VFIO IOMMU support": "Activar soporte VFIO IOMMU",
@@ -3208,6 +3215,7 @@
"If pvesm path returned a DEVICE (LVM):": "Si pvesm path devolvió un DISPOSITIVO (LVM):",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Si pvesm path devolvió un DIRECTORIO (ZFS/BTRFS):",
"If repositories error:": "Si hay error en los repositorios:",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Si el servicio USB/IP se detiene de forma inesperada, deja de compartir el dispositivo mientras sigue conectado para liberarlo. En casos excepcionales solo se libera reiniciando el host.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Si el contenedor ya se había modificado, se restaura su backup nativo anterior. Los directorios compartidos del host no se revierten. Los discos del intento fallido se eliminan si la limpieza termina bien.",
"If the host freezes, remove hostpci entries from": "Si el host se congela, elimina las entradas hostpci de",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.": "Si el canal de imagen tiene una versión nueva, el CT se detiene, se hace un backup, se verifica y se sustituye por la nueva imagen, actualizando el contenedor. Si la actualización falla después de modificar el contenedor, se restaura el backup. Una actualización que se queda a medias se recupera desde el menú de gestión OCI.",
@@ -3402,6 +3410,7 @@
"Install server packages inside CT:": "Instalar paquetes de servidor dentro de CT:",
"Install terminal multiplexers": "Instalar multiplexores de terminales",
"Install the Edge TPU runtime (libedgetpu1-std)": "Instalar el tiempo de ejecución de Edge TPU (libedgetpu1-std)",
"Install the USB/IP dependencies from the configured Debian repositories?": "¿Instalar las dependencias de USB/IP desde los repositorios de Debian configurados?",
"Install this image?": "¿Instalar esta imagen?",
"Install with Cloud-Init script": "Instalar con el script Cloud-Init",
"Install with ISO from UUP Dump": "Instalar con ISO desde UUP Dump",
@@ -3592,6 +3601,7 @@
"Invalid check package:": "Paquete de comprobación inválido:",
"Invalid configuration path:": "Vía de configuración inválida:",
"Invalid consume/export volumes": "Volumen de consumo/exportación inválidos",
"Invalid container device GID": "El GID del dispositivo del contenedor no es válido",
"Invalid container path:": "Carril de contenedor inválido:",
"Invalid credential file path:": "Ruta de archivo credential no válida:",
"Invalid declarative entrypoint": "Punto de entrada declarativo inválido",
@@ -3885,9 +3895,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Línea para pegar (una sola línea, incluido el prefijo \"comando=...\"):",
"LinkStack": "LinkStack",
"Linux Installation Options": "Opciones de instalación de Linux",
"Linux guest setup and migration notes": "Configuración del invitado Linux y notas sobre migración",
"Linux/Mac path:": "Ruta Linux/Mac:",
"LinuxServer Jellyfin with optional GPU passthrough": "Jellyfin de LinuxServer con passthrough de GPU opcional",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB requiere un usuario, base de datos y contraseña",
"LinuxServer Nextcloud with MariaDB and Redis": "Nextcloud de LinuxServer con MariaDB y Redis",
"List Available Disks": "Listar discos disponibles",
"List IOMMU group mapping": "Listar el mapeo del grupo IOMMU",
"List NVMe devices": "Listar dispositivos NVMe",
@@ -4392,6 +4404,7 @@
"Name or part of the option or of the application": "Nombre o parte de la opción o de la aplicación",
"Name:": "Nombre:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Las cuentas con nombre necesitan el modo privado. Detén el contenedor, pon public: false en /config/config.js, vuelve a arrancarlo y crea cada usuario con: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "Borg nativo no está disponible. Instala el paquete borgbackup nativo en este host para usar los backups de Borg.",
"Needed:": "Necesario:",
"Needs": "Necesita",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "No se encontraron /etc/kernel/cmdline ni /etc/default/grub.",
@@ -4467,6 +4480,7 @@
"Next Step Required": "Se requiere el siguiente paso",
"Next Steps:": "Próximos pasos:",
"Next step: stop that VM first, then run": "Siguiente paso: detén esa VM primero y luego ejecuta",
"Nextcloud Web UI": "Interfaz web de Nextcloud",
"Nextcloud configuration cancelled": "Configuración Nextcloud cancelada",
"Nextcloud gives you access to all your files wherever you are.": "Nextcloud te da acceso a todos tus archivos estés donde estés.",
"Nextcloud volume size in GB": "Tamaño del volumen de Nextcloud en GB",
@@ -4640,6 +4654,7 @@
"No duplicate IP addresses": "Sin direcciones IP duplicadas",
"No duplicate repositories found": "No se encontraron repositorios duplicados",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "No quedan dispositivos de controlador/NVMe elegibles después del filtrado SR-IOV. Salto a la comba.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "No hay dispositivos USB compatibles. Se excluyen los concentradores, el almacenamiento masivo y los adaptadores de red.",
"No eligible controllers remain after SR-IOV filtering.": "No quedan controladores elegibles después del filtrado SR-IOV.",
"No encryption key is stored on this host. Choose how to set one up:": "No se almacena ninguna clave de cifrado en este host. Elige cómo configurar una:",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "No se encontraron discos de VM exportables (se excluyen CD-ROM/cloud-init).",
@@ -4724,6 +4739,7 @@
"No self-test log available for": "No hay ningún log de autoprueba disponible para",
"No server IP or hostname provided.": "No se proporcionó ninguna IP de servidor ni nombre de host.",
"No share access test succeeded with guest authentication.": "Ninguna prueba de acceso a recursos compartidos funcionó con autenticación de invitado.",
"No shared devices are available on this server.": "No hay dispositivos compartidos disponibles en este servidor.",
"No shared media content.": "No hay contenido multimedia compartido.",
"No shared mount detected. Applying standard local access.": "No se detectó ningún montaje compartido. Aplicando acceso local estándar.",
"No shares configured.": "No hay recursos compartidos configurados.",
@@ -5119,6 +5135,7 @@
"Path of the Compose file": "Ruta del archivo Compose",
"Path of the oci-compose file": "Ruta del archivo oci-compose",
"Path removed:": "Ruta eliminada:",
"Path to the client SSH public key on this server:": "Ruta de la clave pública SSH del cliente en este servidor:",
"Path:": "Ruta:",
"Paths applied:": "Rutas aplicadas:",
"Paths included in backup": "Rutas incluidas en el backup",
@@ -5360,6 +5377,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux no los instala. La instalación pide cómo conectarse a ellos.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux no valida los contenidos; cualquier archivo de claves que acepte tu PBS se acepta aquí.",
"ProxMenux files:": "Archivos ProxMenux:",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "ProxMenux genera la contraseña del administrador y la muestra al final de la instalación. Cámbiala después del primer inicio de sesión.",
"ProxMenux logo applied": "Logotipo de ProxMenux aplicado",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux solo actúa como Lanzador del script.",
"ProxMenux saved it locally at:": "ProxMenux lo guardó localmente en:",
@@ -5573,6 +5591,7 @@
"Recreating the container:": "Recreando el contenedor:",
"Recursive DNS resolver.": "Resolvedor DNS recursivo.",
"Recursive and authoritative DNS server.": "Servidor DNS recursivo y autoritativo.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "El bloqueo con Redis se configura automáticamente. Se conservan los archivos de configuración existentes de Nextcloud.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "Actualizar el índice APT y verificar los repositorios:",
"Refresh catalog": "Actualizar catálogo",
@@ -5656,6 +5675,7 @@
"Remove Proxmox NFS storage:": "Eliminar el almacenamiento Proxmox NFS:",
"Remove Proxmox iSCSI storage:": "Eliminar el almacenamiento iSCSI de Proxmox:",
"Remove Secure Gateway? State will be preserved.": "¿Eliminar Secure Gateway? Se conservará el estado.",
"Remove a remote USB connection": "Eliminar una conexión USB remota",
"Remove custom paths": "Eliminar rutas personalizadas",
"Remove disk references from affected VM(s)/CT(s) config": "Eliminar referencias de disco de la configuración de VM/CT(s) afectados",
"Remove every registered gasket DKMS version": "Eliminar todas las versiones de gasket registradas en DKMS",
@@ -5676,6 +5696,7 @@
"Remove the lock left on a container by an interrupted task": "Quitar el bloqueo que deja en un contenedor una tarea interrumpida",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "¿Eliminar las credenciales guardadas de {registry}?\nLas aplicaciones en marcha seguirán funcionando.",
"Remove the unprivileged flag from configuration:": "Elimina el flag unprivileged de la configuración:",
"Remove this connection and detach its USB device?": "¿Eliminar esta conexión y desconectar su dispositivo USB?",
"Remove unused packages and their config": "Eliminar paquetes no utilizados y su configuración",
"Remove: delete the application and its containers": "Eliminar: la aplicación y sus contenedores",
"Removed": "Remoto",
@@ -5898,9 +5919,11 @@
"Run mode: Unattended": "Modo de ejecución: desatendido",
"Run now": "Ejecutar ahora",
"Run security audit now": "Ejecutar la auditoría de seguridad ahora",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Ejecuta el cliente dentro de la VM Linux que necesita el USB. Instala usbip, openssh-client y kmod y copia usbip_manager.py en la misma ruta que en el servidor. Configura la autenticación por clave y verifica la clave del servidor antes de añadir la conexión. El cliente usa el kernel del invitado y puede reconectarse después de migrarlo mientras el USB físico sigue en el servidor. Los LXC sin privilegios y Home Assistant OS necesitan su propia configuración de kernel, dispositivos o complementos. Solo un cliente puede usar cada dispositivo físico a la vez.",
"Run the following inside the VM:": "Ejecuta lo siguiente dentro de la VM:",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Ejecuta el cambio de canal de versiones desde una sesión SSH o la consola del host Proxmox con:",
"Run the update from an SSH session or the Proxmox host console with:": "Ejecuta la actualización desde una sesión SSH o la consola del host Proxmox con:",
"Run this menu as root.": "Ejecuta este menú como root.",
"Run this operation as root.": "Ejecuta esta operación como root.",
"Run upgrade checklist script:": "Ejecuta el script de la lista de verificación de actualización:",
"Running": "En ejecución",
@@ -6125,6 +6148,7 @@
"Select a new language for the menu:": "Selecciona un nuevo idioma para el menú:",
"Select a post-installation script:": "Selecciona un script de post-instalación:",
"Select a pre-configured Linux VM script to execute:": "Selecciona un script de VM Linux preconfigurado para ejecutar:",
"Select a saved USB/IP device:": "Selecciona un dispositivo USB/IP guardado:",
"Select a script or action:": "Selecciona un script o acción:",
"Select a share to delete:": "Selecciona un recurso compartido para eliminar:",
"Select a specific Coral or USB node, not the whole /dev": "Selecciona un Coral específico o nodo USB, no todo /dev",
@@ -6202,6 +6226,7 @@
"Select the Linux distribution to install:": "Selecciona la distribución de Linux para instalar:",
"Select the NAS system to install:": "Selecciona el sistema NAS a instalar:",
"Select the NVIDIA driver version to install:": "Selecciona la versión del controlador NVIDIA para instalar:",
"Select the USB device to share:": "Selecciona el dispositivo USB que quieres compartir:",
"Select the VM to add the GPU to:": "Selecciona la VM a la que añadir la GPU:",
"Select the VM to which you want to add disks:": "Selecciona la VM a la que deseas añadir discos:",
"Select the VM where you want to import the disk image:": "Selecciona la VM donde deseas importar la imagen del disco:",
@@ -6226,6 +6251,7 @@
"Select the parent job to attach to:": "Selecciona el trabajo principal al que adjuntarlo:",
"Select the paths to restore (one per backup entry):": "Selecciona las rutas para restaurar (una por entrada de backup):",
"Select the privileged LXC container to convert:": "Selecciona el contenedor LXC privilegiado para convertir:",
"Select the remote USB device:": "Selecciona el dispositivo USB remoto:",
"Select the storage that will receive the backups:": "Selecciona el almacenamiento que recibirá los backups:",
"Select the storage volume for disk import:": "Selecciona el volumen de almacenamiento para la importación de disco:",
"Select the sync interval (in hours):": "Selecciona el intervalo de sincronización (en horas):",
@@ -6312,6 +6338,7 @@
"Server IDs to test against, separated by commas (optional)": "ID de los servidores contra los que probar, separados por comas (opcional)",
"Server IP/hostname is correct": "La IP/nombre de host del servidor es correcta",
"Server IP:": "IP del servidor:",
"Server SSH port:": "Puerto SSH del servidor:",
"Server is accessible:": "El servidor es accesible:",
"Server is online": "El servidor está en línea",
"Server is running": "El servidor está funcionando",
@@ -6373,6 +6400,7 @@
"Share Access Error": "Error de acceso al recurso compartido",
"Share Name": "Nombre compartido",
"Share Options": "Opciones para compartir",
"Share a local USB device": "Compartir un dispositivo USB local",
"Share added successfully.": "Recurso compartido añadido correctamente.",
"Share deleted and Samba service restarted.": "Compartir eliminado y servicio Samba reiniciado.",
"Share exists:": "Compartir existe:",
@@ -6380,6 +6408,7 @@
"Share name:": "Nombre compartido:",
"Share not found on server:": "Compartir no encontrado en el servidor:",
"Share path:": "Compartir ruta:",
"Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.": "Comparte dispositivos USB mediante SSH o conecta este equipo Linux a un servidor USB/IP.",
"Share updated successfully.": "Compartir actualizado con éxito.",
"Share:": "Compartir:",
"ShareCoveX panel": "Panel de ShareCoveX",
@@ -6649,6 +6678,8 @@
"Still has issues": "Todavía tiene problemas",
"Stop - shuts the guest down during backup": "Stop - apaga la VM o CT durante el backup",
"Stop it first and run this option again.": "Detenlo primero y ejecuta esta opción nuevamente.",
"Stop sharing a USB device": "Dejar de compartir un dispositivo USB",
"Stop sharing this device? An attached remote client will disconnect.": "¿Dejar de compartir este dispositivo? Se desconectará el cliente remoto que lo esté usando.",
"Stop that VM, then choose:": "Detén ese VM, luego elige:",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Detén el CT, desmonta el disco en el HOST y vuelve a montarlo con:",
"Stop the VM/CT before formatting this disk.": "Detén el VM/CT antes de formatear este disco.",
@@ -7002,6 +7033,7 @@
"The container could not be removed automatically:": "El contenedor no se puede retirar automáticamente:",
"The container could not be started:": "No se podía iniciar el contenedor:",
"The container creation does not match the prepared instance": "La creación de contenedores no coincide con la instancia preparada",
"The container device settings changed since the last recorded operation": "La configuración del dispositivo del contenedor ha cambiado desde la última operación registrada",
"The container devices do not match the saved record": "Los dispositivos del contenedor no coinciden con el registro guardado",
"The container did not stop to update its persistent configuration:": "El contenedor no se detuvo para actualizar su configuración persistente:",
"The container did not stop; its disks are not touched": "El contenedor no se detuvo; sus discos no se tocan",
@@ -7276,6 +7308,7 @@
"The record no longer belongs to this operation": "El registro ya no pertenece a esta operación",
"The record of a member was replaced; the assembly is not resumed": "El registro de un miembro fue reemplazado; el ensamblado no se reanuda",
"The record or diagnosis could not be completed; no update was run.": "El registro o el diagnóstico no se pudo completar; no se realizó ninguna actualización.",
"The recorded container device GID does not match its configuration": "El GID registrado del dispositivo del contenedor no coincide con su configuración",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "La recuperación no terminó. Revisa el log y elige \"Recuperar la instalación anterior\" de nuevo para este contenedor en el menú de gestión OCI.",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "La recuperación no terminó. Revisa el log y elige \"{choice}\" de nuevo para este contenedor en el menú de gestión OCI.",
"The registry authentication file is invalid; it was not changed.": "El fichero de autenticación del registro no es válido; no se ha modificado.",
@@ -7343,6 +7376,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "La GPU Intel seleccionada tiene soporte de reinicio no FLR y subtipo desconocido",
"The selected Intel GPU is currently in power state D3cold": "La GPU Intel seleccionada se encuentra actualmente en estado de energía D3cold",
"The selected Intel render device does not exist:": "El dispositivo de renderización Intel seleccionado no existe:",
"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 VM": "La máquina virtual seleccionada",
"The selected VM is running.": "La VM seleccionada se está ejecutando.",
"The selected base folder does not exist and could not be created:": "La carpeta base seleccionada no existe y no se pudo crear:",
@@ -7680,6 +7714,9 @@
"USB drives mounted now:": "Unidades USB montadas ahora:",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "Nodo de dispositivo USB o serie",
"USB/IP configuration updated. Open status to check the connection.": "Configuración de USB/IP actualizada. Abre el estado para comprobar la conexión.",
"USB/IP over SSH": "USB/IP mediante SSH",
"USB/IP server IP address or DNS hostname:": "Dirección IP o nombre DNS del servidor USB/IP:",
"UUP Dump script not found.": "No se encontró el script de UUP Dump.",
"UUp Dump ISO creator Custom": "Creador de ISO personalizada con UUP Dump",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity es un servidor doméstico gratuito, ligero y fácil de usar para tus cómics y ebooks. Úsalo para acceder a tus archivos desde cualquier lugar, con una tableta, un lector electrónico, un teléfono o un ordenador.",
@@ -7834,6 +7871,7 @@
"Untrusted or modified NVIDIA hook": "Hook NVIDIA sin confianza o modificado",
"Unused image removed from the cache:": "Imagen sin uso eliminada de la caché:",
"Unused images removed from the cache:": "Imágenes sin uso eliminadas de la caché:",
"Unused local loopback TCP port for this device:": "Puerto TCP de loopback local libre para este dispositivo:",
"Update": "Actualización",
"Update Available": "Actualización disponible",
"Update Ceph repository (Only if using Ceph):": "Actualiza el repositorio de Ceph (solo si usas Ceph):",
@@ -7932,6 +7970,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Utiliza un LXC privilegiado para flujos de trabajo de servidor/cliente NFS.",
"Use a privileged LXC for Samba client/server workflows.": "Utiliza un LXC privilegiado para flujos de trabajo de cliente/servidor Samba.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Escribe el nombre de un registro, por ejemplo ghcr.io o registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Usa un proxy inverso HTTPS con un certificado de confianza. Actualiza Nextcloud de una versión principal a la siguiente, sin saltarte ninguna.",
"Use an existing SSH private key file on this host": "Usar un archivo de clave privada SSH existente en este host",
"Use an existing keyfile": "Usar un archivo de claves existente",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Escribe una referencia de imagen, por ejemplo ghcr.io/owner/app:latest.",
@@ -7945,6 +7984,7 @@
"Use existing filesystem": "Usar el sistema de archivos existente",
"Use existing: mount without formatting": "Usar existente: montar sin formatear",
"Use images only if the directory is on suitable storage.": "Utiliza imágenes solo si el directorio está en un almacenamiento adecuado.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Usa almacenamiento gestionado para /config y MariaDB. Para /data puedes elegir un directorio del host/NAS, que necesita su propia copia de seguridad.",
"Use mp1/mp2/... for extra mount points.": "Utiliza mp1/mp2/... para puntos de montaje adicionales.",
"Use only when unprivileged containers cannot meet your needs": "Úselo solo cuando los contenedores sin privilegios no puedan satisfacer sus necesidades",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Utiliza la opción 1 para añadir un disco local (como almacenamiento Proxmox y/o como montaje de host).",
@@ -8117,6 +8157,7 @@
"View Disk Storages": "Ver almacenamientos en disco",
"View Mount Points": "Ver puntos de montaje",
"View NFS Mounts (pvesm + fstab)": "Ver montajes NFS (pvesm + fstab)",
"View USB/IP status": "Ver el estado de USB/IP",
"View backup metadata": "Ver metadatos de backup",
"View current paths": "Ver rutas actuales",
"View existing folders in /mnt": "Ver carpetas existentes en /mnt",
+41
View File
@@ -246,6 +246,7 @@
"Aborted": "Avorté",
"About to restore": "Sur le point de restaurer",
"Absolute directory path to use as backup target:": "Chemin du répertoire absolu à utiliser comme cible de sauvegarde :",
"Absolute path of the client SSH private key:": "Chemin absolu de la clé privée SSH du client :",
"Absolute path to a file or directory you want backed up:": "Chemin absolu vers un fichier ou un répertoire que vous souhaitez sauvegarder :",
"Acceleration": "Accélération",
"Acceleration configuration cancelled": "Configuration d'accélération annulée",
@@ -619,6 +620,8 @@
"Authentication:": "Authentification:",
"Authorization failed": "L'autorisation a échoué",
"Authorization successful": "Autorisation réussie",
"Authorize a client SSH public key": "Autoriser la clé publique SSH d’un client",
"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 this key on the server": "Autoriser cette clé sur le serveur",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Autoriser via 'pct exec' (le serveur Borg est un LXC sur un hôte PVE sur lequel je peux me connecter en SSH)",
"Authorized": "Autorisé",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Utilitaires en conflit supprimés",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Connectez les appareils Zigbee à MQTT via une interface web de configuration.",
"Connect a Coral Accelerator and try again.": "Connectez un accélérateur Coral et réessayez.",
"Connect a remote USB device": "Connecter un périphérique USB distant",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Connectez-le ou supprimez-le du conteneur dans Proxmox (Ressources) et réexécutez la récupération.",
"Connect your devices and users together in your own secure virtual private network.": "Connectez vos appareils et utilisateurs ensemble dans votre propre réseau privé virtuel sécurisé.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Connectez vos appareils à un réseau de superposition sécurisé basé sur WireGuard® avec des contrôles d'accès SSO, MFA et granulaires.",
@@ -1961,12 +1965,14 @@
"Directus administrator": "Administrateur Directus",
"Disable NIC Offloading (Intel e1000e)": "Désactiver le déchargement de la carte réseau (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Désactivez Secure Boot dans le firmware UEFI et relancez le programme d'installation. Rien n'a été modifié.",
"Disable USB/IP server": "Désactiver le serveur USB/IP",
"Disable autostart on conflicting VM": "Désactiver le démarrage automatique sur la VM en conflit",
"Disable autostart on one VM to avoid startup conflicts.": "Désactivez le démarrage automatique sur une VM pour éviter les conflits de démarrage.",
"Disable job": "Désactiver le travail",
"Disable kernel audit messages (optional but recommended):": "Désactivez les messages d'audit du noyau (facultatif mais recommandé) :",
"Disable onboot on affected VM(s)/CT(s)": "Désactiver le démarrage sur les VM/CT concernés",
"Disable portmapper/rpcbind": "Désactiver portmapper/rpcbind",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Désactiver le serveur et libérer tous les périphériques USB gérés par ProxMenux ? Les sélections enregistrées seront conservées pour la prochaine activation.",
"Disabled": "Désactivé",
"Disabling High Availability services...": "Désactivation des services haute disponibilité...",
"Disabling ZFS autotrim applied by ProxMenux...": "Désactivation du découpage automatique ZFS appliqué par ProxMenux...",
@@ -2199,6 +2205,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Activez le Bureau à distance (RDP) avant de désactiver l'affichage virtuel.",
"Enable SSD emulation for this disk?": "Activer l'émulation SSD pour ce disque ?",
"Enable TCP BBR/Fast Open control": "Activer le contrôle TCP BBR/Fast Open",
"Enable USB/IP server": "Activer le serveur USB/IP",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Activer le transcodage VA-API et HDR10/Dolby Vision pour la cartographie des tons SDR",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Activer le VA-API, l'encodage matériel et la cartographie des tons OpenCL dans Jellyfin",
"Enable VFIO IOMMU support": "Activer la prise en charge de VFIO IOMMU",
@@ -3212,6 +3219,7 @@
"If pvesm path returned a DEVICE (LVM):": "Si pvesm path a renvoyé un PÉRIPHÉRIQUE (LVM) :",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Si pvesm path a renvoyé un RÉPERTOIRE (ZFS/BTRFS) :",
"If repositories error:": "Si erreur de référentiels :",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Si le service USB/IP s'arrête de manière inattendue, arrêtez le partage tant que le périphérique est encore branché pour le libérer. Dans de rares cas, seul un redémarrage de l'hôte le libère.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Si le conteneur a déjà été modifié, sa sauvegarde native précédente est restaurée. Les répertoires hôtes partagés ne sont pas rétablis. Les disques de la tentative ayant échoué sont supprimés une fois le nettoyage réussi.",
"If the host freezes, remove hostpci entries from": "Si l'hôte se bloque, supprimez les entrées hostpci de",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If anything fails, the backup is restored.": "Si le canal d'image a une nouvelle version, le CT est arrêté, sauvegardé et vérifié, puis remplacé par la nouvelle image pour mettre à jour le conteneur. Si quelque chose échoue, la sauvegarde est restaurée.",
@@ -3406,6 +3414,7 @@
"Install server packages inside CT:": "Installez les packages de serveur dans CT :",
"Install terminal multiplexers": "Installer des multiplexeurs de terminaux",
"Install the Edge TPU runtime (libedgetpu1-std)": "Installez le runtime Edge TPU (libedgetpu1-std)",
"Install the USB/IP dependencies from the configured Debian repositories?": "Installer les dépendances USB/IP depuis les dépôts Debian configurés ?",
"Install this image?": "Installer cette image ?",
"Install with Cloud-Init script": "Installer avec le script Cloud-Init",
"Install with ISO from UUP Dump": "Installer avec ISO à partir de UUP Dump",
@@ -3596,6 +3605,7 @@
"Invalid check package:": "Dossier de contrôle non valide:",
"Invalid configuration path:": "Chemin de configuration non valide & #160;:",
"Invalid consume/export volumes": "Volumes de consommation/d'exportation non valides",
"Invalid container device GID": "GID de périphérique du conteneur invalide",
"Invalid container path:": "Voie du conteneur non valide:",
"Invalid credential file path:": "Chemin de fichier credential incorrect :",
"Invalid declarative entrypoint": "Point de déclaration non valide",
@@ -3890,9 +3900,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Ligne à coller (une seule ligne, incluant le préfixe \"command=...\") :",
"LinkStack": "LinkStack",
"Linux Installation Options": "Options d'installation Linux",
"Linux guest setup and migration notes": "Configuration des invités Linux et notes de migration",
"Linux/Mac path:": "Chemin Linux/Mac :",
"LinuxServer Jellyfin with optional GPU passthrough": "LinuxServer Jellyfin avec passage GPU optionnel",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB requires un utilisateur, une base de données et un mot de passe",
"LinuxServer Nextcloud with MariaDB and Redis": "Nextcloud de LinuxServer avec MariaDB et Redis",
"List Available Disks": "Liste des disques disponibles",
"List IOMMU group mapping": "Répertorier le mappage de groupe IOMMU",
"List NVMe devices": "Répertorier les appareils NVMe",
@@ -4397,6 +4409,7 @@
"Name or part of the option or of the application": "Nom ou partie de l'option ou de l'application",
"Name:": "Nom:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Les comptes nommés nécessitent un mode privé. Arrêter le conteneur, définir public: false in /config/config.js, recommencer et créer chaque utilisateur avec: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge ajouter <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "La version native de Borg n’est pas disponible. Installez le paquet natif borgbackup sur cet hôte pour utiliser les sauvegardes Borg.",
"Needed:": "Nécessaire :",
"Needs": "Nécessite",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Ni /etc/kernel/cmdline ni /etc/default/grub n'ont été trouvés.",
@@ -4472,6 +4485,7 @@
"Next Step Required": "Prochaine étape requise",
"Next Steps:": "Prochaines étapes :",
"Next step: stop that VM first, then run": "Étape suivante : arrêtez d'abord cette VM, puis exécutez",
"Nextcloud Web UI": "Interface web de Nextcloud",
"Nextcloud configuration cancelled": "Configuration Nextcloud annulée",
"Nextcloud gives you access to all your files wherever you are.": "Nextcloud vous donne accès à tous vos fichiers où que vous soyez.",
"Nextcloud volume size in GB": "Taille du volume Nextcloud en GB",
@@ -4646,6 +4660,7 @@
"No duplicate IP addresses": "Pas d'adresses IP en double",
"No duplicate repositories found": "Aucun référentiel en double trouvé",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Il ne reste aucun périphérique contrôleur/NVMe éligible après le filtrage SR-IOV. Saut.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Aucun périphérique USB éligible trouvé. Les concentrateurs, les périphériques de stockage de masse et les adaptateurs réseau sont exclus.",
"No eligible controllers remain after SR-IOV filtering.": "Il ne reste aucun contrôleur éligible après le filtrage SR-IOV.",
"No encryption key is stored on this host. Choose how to set one up:": "Aucune clé de cryptage n’est stockée sur cet hôte. Choisissez comment en configurer un :",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Aucun disque de VM exportable n'a été trouvé (les CD-ROM/cloud-init sont exclus).",
@@ -4730,6 +4745,7 @@
"No self-test log available for": "Aucun journal d'autotest disponible pour",
"No server IP or hostname provided.": "Aucune adresse IP de serveur ou nom d'hôte fourni.",
"No share access test succeeded with guest authentication.": "Aucun test d'accès au partage n'a réussi avec l'authentification des invités.",
"No shared devices are available on this server.": "Aucun périphérique partagé n'est disponible sur ce serveur.",
"No shared media content.": "Pas de contenu multimédia partagé.",
"No shared mount detected. Applying standard local access.": "Aucun montage partagé détecté. Application d'un accès local standard.",
"No shares configured.": "Aucun partage configuré.",
@@ -5128,6 +5144,7 @@
"Path of the Compose file": "Chemin du fichier Composer",
"Path of the oci-compose file": "Chemin du fichier oci-compose",
"Path removed:": "Chemin supprimé :",
"Path to the client SSH public key on this server:": "Chemin de la clé publique SSH du client sur ce serveur :",
"Path:": "Chemin:",
"Paths applied:": "Chemins appliqués :",
"Paths included in backup": "Chemins inclus dans la sauvegarde",
@@ -5370,6 +5387,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux ne les installe pas. L'installation demande comment s'y connecter.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux ne valide pas le contenu ;tout fichier clé accepté par votre PBS est accepté ici.",
"ProxMenux files:": "Fichiers ProxMenux :",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "ProxMenux génère le mot de passe de l'administrateur et l'affiche à la fin de l'installation. Modifiez-le après la première connexion.",
"ProxMenux logo applied": "Logo ProxMenux appliqué",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux agit uniquement comme un lanceur : une fois le script démarré, le contrôle quitte ProxMenux.",
"ProxMenux saved it locally at:": "ProxMenux l'a enregistré localement à :",
@@ -5590,6 +5608,7 @@
"Recreation prepared": "Loisirs préparés",
"Recursive DNS resolver.": "Résolveur DNS récursif.",
"Recursive and authoritative DNS server.": "Serveur DNS récursif et faisant autorité.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "Le verrouillage avec Redis est configuré automatiquement. Les fichiers de configuration existants de Nextcloud sont conservés.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "Actualisez l'index APT et vérifiez les référentiels :",
"Refresh catalog": "Actualiser le catalogue",
@@ -5673,6 +5692,7 @@
"Remove Proxmox NFS storage:": "Supprimez le stockage Proxmox NFS :",
"Remove Proxmox iSCSI storage:": "Supprimez le stockage iSCSI Proxmox :",
"Remove Secure Gateway? State will be preserved.": "Supprimer Secure Gateway ? L'État sera préservé.",
"Remove a remote USB connection": "Supprimer une connexion USB distante",
"Remove custom paths": "Supprimer les chemins personnalisés",
"Remove disk references from affected VM(s)/CT(s) config": "Supprimer les références de disque de la configuration des VM/CT concernées",
"Remove every registered gasket DKMS version": "Supprimer toutes les versions DKMS de gasket enregistrées",
@@ -5693,6 +5713,7 @@
"Remove the lock left on a container by an interrupted task": "Supprimer le verrou laissé sur un conteneur par une tâche interrompue",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Supprimer les informations d'identification enregistrées pour {registry} ?\nLes applications en cours d’exécution continueront de fonctionner.",
"Remove the unprivileged flag from configuration:": "Supprimez l'indicateur sans privilèges de la configuration :",
"Remove this connection and detach its USB device?": "Supprimer cette connexion et détacher son périphérique USB ?",
"Remove unused packages and their config": "Supprimer les packages inutilisés et leur configuration",
"Remove: delete the application and its containers": "Supprimer: supprimer l'application et ses conteneurs",
"Removed": "Supprimé",
@@ -5915,9 +5936,11 @@
"Run mode: Unattended": "Mode d'exécution : sans surveillance",
"Run now": "Exécuter maintenant",
"Run security audit now": "Exécutez un audit de sécurité maintenant",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Exécutez le client dans la VM Linux qui a besoin du périphérique USB. Installez usbip, openssh-client et kmod, puis copiez usbip_manager.py au même chemin que sur le serveur. Configurez l'authentification par clé et vérifiez la clé d'hôte du serveur avant d'ajouter une connexion. Le client utilise le noyau de l'invité et peut se reconnecter après une migration tandis que le périphérique USB physique reste sur le serveur. Les LXC non privilégiés et Home Assistant OS nécessitent une configuration spécifique du noyau, des périphériques ou des modules complémentaires. Un périphérique physique ne peut être attaché qu'à un seul client à la fois.",
"Run the following inside the VM:": "Exécutez la commande suivante dans la VM :",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Exécutez le commutateur de canal de version à partir d'une session SSH ou de la console hôte Proxmox avec :",
"Run the update from an SSH session or the Proxmox host console with:": "Exécutez la mise à jour depuis une session SSH ou la console hôte Proxmox avec :",
"Run this menu as root.": "Exécutez ce menu en tant que root.",
"Run this operation as root.": "Exécutez cette opération en tant que root.",
"Run upgrade checklist script:": "Exécutez le script de liste de contrôle de mise à niveau :",
"Running": "En cours d'exécution",
@@ -6142,6 +6165,7 @@
"Select a new language for the menu:": "Sélectionnez une nouvelle langue pour le menu :",
"Select a post-installation script:": "Sélectionnez un script de post-installation :",
"Select a pre-configured Linux VM script to execute:": "Sélectionnez un script de machine virtuelle Linux préconfiguré à exécuter :",
"Select a saved USB/IP device:": "Sélectionnez un périphérique USB/IP enregistré :",
"Select a script or action:": "Sélectionnez un script ou une action :",
"Select a share to delete:": "Sélectionnez un partage à supprimer :",
"Select a specific Coral or USB node, not the whole /dev": "Sélectionnez un noeud spécifique Coral ou USB, pas l'ensemble /dev",
@@ -6219,6 +6243,7 @@
"Select the Linux distribution to install:": "Sélectionnez la distribution Linux à installer :",
"Select the NAS system to install:": "Sélectionnez le système NAS à installer :",
"Select the NVIDIA driver version to install:": "Sélectionnez la version du pilote NVIDIA à installer :",
"Select the USB device to share:": "Sélectionnez le périphérique USB à partager :",
"Select the VM to add the GPU to:": "Sélectionnez la VM à laquelle ajouter le GPU :",
"Select the VM to which you want to add disks:": "Sélectionnez la VM à laquelle vous souhaitez ajouter des disques :",
"Select the VM where you want to import the disk image:": "Sélectionnez la VM sur laquelle vous souhaitez importer l'image disque :",
@@ -6243,6 +6268,7 @@
"Select the parent job to attach to:": "sélectionnez la tâche parent à attacher :",
"Select the paths to restore (one per backup entry):": "Sélectionnez les chemins à restaurer (un par entrée de sauvegarde) :",
"Select the privileged LXC container to convert:": "Sélectionnez le conteneur LXC privilégié à convertir :",
"Select the remote USB device:": "Sélectionnez le périphérique USB distant :",
"Select the storage that will receive the backups:": "Sélectionnez le stockage qui recevra les sauvegardes :",
"Select the storage volume for disk import:": "Sélectionnez le volume de stockage pour l'importation de disque :",
"Select the sync interval (in hours):": "Sélectionnez l'intervalle de synchronisation (en heures) :",
@@ -6329,6 +6355,7 @@
"Server IDs to test against, separated by commas (optional)": "ID de serveur à tester, séparés par des virgules (facultatif)",
"Server IP/hostname is correct": "L'adresse IP/le nom d'hôte du serveur est correct",
"Server IP:": "IP du serveur :",
"Server SSH port:": "Port SSH du serveur :",
"Server is accessible:": "Le serveur est accessible :",
"Server is online": "Le serveur est en ligne",
"Server is running": "Le serveur est en cours d'exécution",
@@ -6390,6 +6417,7 @@
"Share Access Error": "Erreur d'accès au partage",
"Share Name": "Partager le nom",
"Share Options": "Options de partage",
"Share a local USB device": "Partager un périphérique USB local",
"Share added successfully.": "Partage ajouté avec succès.",
"Share deleted and Samba service restarted.": "Partage supprimé et service Samba redémarré.",
"Share exists:": "Le partage existe :",
@@ -6397,6 +6425,7 @@
"Share name:": "Nom du partage :",
"Share not found on server:": "Partage introuvable sur le serveur :",
"Share path:": "Chemin de partage :",
"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 updated successfully.": "Partage mis à jour avec succès.",
"Share:": "Partager:",
"ShareCoveX panel": "Panneau ShareCoveX",
@@ -6665,6 +6694,8 @@
"Still has issues": "A encore des problèmes",
"Stop - shuts the guest down during backup": "Arrêter - arrête l'invité pendant la sauvegarde",
"Stop it first and run this option again.": "Arrêtez-le d'abord et réexécutez cette option.",
"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 that VM, then choose:": "Arrêtez ce VM, puis choisissez :",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Arrêtez le CT, démontez le disque sur le HOST et remontez avec :",
"Stop the VM/CT before formatting this disk.": "Arrêtez la VM/CT avant de formater ce disque.",
@@ -7019,6 +7050,7 @@
"The container could not be removed automatically:": "Le conteneur n'a pas pu être enlevé automatiquement:",
"The container could not be started:": "Le conteneur n'a pas pu être démarré:",
"The container creation does not match the prepared instance": "La création du conteneur ne correspond pas à l'instance préparée",
"The container device settings changed since the last recorded operation": "La configuration du périphérique du conteneur a changé depuis la dernière opération enregistrée",
"The container devices do not match the saved record": "Les périphériques de conteneur ne correspondent pas à l'enregistrement enregistré",
"The container did not stop to update its persistent configuration:": "Le conteneur ne s'est pas arrêté pour mettre à jour sa configuration persistante:",
"The container did not stop; its disks are not touched": "Le conteneur ne s'est pas arrêté; ses disques ne sont pas touchés",
@@ -7300,6 +7332,7 @@
"The record no longer belongs to this operation": "Le record n'appartient plus à cette operation",
"The record of a member was replaced; the assembly is not resumed": "Le procès-verbal d'un membre a été remplacé; l'assemblée n'est pas reprise",
"The record or diagnosis could not be completed; no update was run.": "Le dossier ou le diagnostic n'a pu être complété; aucune mise à jour n'a été effectuée.",
"The recorded container device GID does not match its configuration": "Le GID enregistré du périphérique du conteneur ne correspond pas à sa configuration",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "La récupération n'est pas terminée. Passez en revue le journal et choisissez « Recover » pour ce conteneur dans le menu de gestion OCI.",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "La restauration ne s'est pas terminée. Consultez le journal et choisissez de nouveau « {choice} » pour ce conteneur dans le menu de gestion OCI.",
"The registry authentication file is invalid; it was not changed.": "Le fichier d'authentification du registre n'est pas valide ; cela n'a pas été changé.",
@@ -7367,6 +7400,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "Le GPU Intel sélectionné prend en charge la réinitialisation non FLR et sous-type inconnu",
"The selected Intel GPU is currently in power state D3cold": "Le GPU Intel sélectionné est actuellement en état d'alimentation D3cold",
"The selected Intel render device does not exist:": "Le périphérique de rendu Intel sélectionné n'existe pas :",
"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 VM": "La VM sélectionnée",
"The selected VM is running.": "La VM sélectionnée est en cours d'exécution.",
"The selected base folder does not exist and could not be created:": "Le dossier de base sélectionné n'existe pas et n'a pas pu être créé :",
@@ -7706,6 +7740,9 @@
"USB drives mounted now:": "Clés USB montées maintenant :",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "nœud de périphérique USB ou série",
"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 :",
"UUP Dump script not found.": "Script de vidage UUP introuvable.",
"UUp Dump ISO creator Custom": "UUp Dump Créateur ISO Personnalisé",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity est un serveur à domicile gratuit, léger et facile à utiliser pour vos BD et ebooks. Utilisez-le pour accéder à vos fichiers de n'importe où, avec une tablette, un lecteur électronique, un téléphone ou un ordinateur.",
@@ -7862,6 +7899,7 @@
"Untrusted or modified NVIDIA hook": "Crochet NVIDIA non fiable ou modifié",
"Unused image removed from the cache:": "Image non utilisée retirée du cache :",
"Unused images removed from the cache:": "Images inutilisées supprimées du cache :",
"Unused local loopback TCP port for this device:": "Port TCP local de bouclage inutilisé pour ce périphérique :",
"Update": "Mise à jour",
"Update Available": "mise à jour disponible",
"Update Ceph repository (Only if using Ceph):": "Mettre à jour le référentiel Ceph (uniquement si vous utilisez Ceph) :",
@@ -7960,6 +7998,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Utilisez un LXC privilégié pour les workflows serveur/client NFS.",
"Use a privileged LXC for Samba client/server workflows.": "Utilisez un LXC privilégié pour les workflows client/serveur Samba.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Utilisez un nom d'hôte de registre, par exemple ghcr.io ou registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Utilisez un proxy inverse HTTPS avec un certificat de confiance. Mettez Nextcloud à niveau vers la version majeure suivante, sans en sauter.",
"Use an existing SSH private key file on this host": "Utiliser un fichier de clé privée SSH existant sur cet hôte",
"Use an existing keyfile": "utiliser un fichier de clés existant",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Utilisez une référence d'image, par exemple ghcr.io/owner/app:latest.",
@@ -7973,6 +8012,7 @@
"Use existing filesystem": "Utiliser le système de fichiers existant",
"Use existing: mount without formatting": "Utiliser existant : monter sans formatage",
"Use images only if the directory is on suitable storage.": "Utilisez des images uniquement si le répertoire se trouve sur un stockage approprié.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Utilisez un stockage géré pour /config et MariaDB. Pour /data, vous pouvez choisir un répertoire de l’hôte/NAS, qui nécessite sa propre sauvegarde.",
"Use mp1/mp2/... for extra mount points.": "Utilisez mp1/mp2/... pour des points de montage supplémentaires.",
"Use only when unprivileged containers cannot meet your needs": "À utiliser uniquement lorsque les conteneurs non privilégiés ne peuvent pas répondre à vos besoins",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Utilisez l'option 1 pour ajouter un disque local (comme stockage Proxmox et/ou comme montage hôte).",
@@ -8143,6 +8183,7 @@
"View Disk Storages": "Afficher les stockages sur disque",
"View Mount Points": "Afficher les points de montage",
"View NFS Mounts (pvesm + fstab)": "Afficher les montages NFS (pvesm + fstab)",
"View USB/IP status": "Afficher l'état USB/IP",
"View backup metadata": "Afficher les métadonnées de sauvegarde",
"View current paths": "afficher les chemins actuels",
"View existing folders in /mnt": "Afficher les dossiers existants dans /mnt",
+41
View File
@@ -246,6 +246,7 @@
"Aborted": "Interrotto",
"About to restore": "Ripristino imminente di",
"Absolute directory path to use as backup target:": "Percorso assoluto della directory di destinazione del backup:",
"Absolute path of the client SSH private key:": "Percorso assoluto della chiave privata SSH del client:",
"Absolute path to a file or directory you want backed up:": "Percorso assoluto del file o della directory da includere nel backup:",
"Acceleration": "Accelerazione",
"Acceleration configuration cancelled": "Configurazione accelerazione annullata",
@@ -619,6 +620,8 @@
"Authentication:": "Autenticazione:",
"Authorization failed": "Autorizzazione fallita",
"Authorization successful": "Autorizzazione riuscita",
"Authorize a client SSH public key": "Autorizza la chiave pubblica SSH di un client",
"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 this key on the server": "Autorizzare questa chiave sul server",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Autorizza tramite 'pct exec' (il server Borg è un LXC su un host PVE a cui posso accedere tramite SSH)",
"Authorized": "Autorizzato",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Utilità in conflitto rimosse",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Collega i dispositivi Zigbee a MQTT tramite un’interfaccia web di configurazione.",
"Connect a Coral Accelerator and try again.": "Collega un Coral Accelerator e riprova.",
"Connect a remote USB device": "Collega un dispositivo USB remoto",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Collegalo o rimuovilo dal container in Proxmox (Risorse), poi ripeti il recupero.",
"Connect your devices and users together in your own secure virtual private network.": "Collega dispositivi e utenti nella tua rete privata virtuale sicura.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Collega i tuoi dispositivi in una rete overlay sicura basata su WireGuard®, con SSO, MFA e controlli granulari degli accessi.",
@@ -1961,12 +1965,14 @@
"Directus administrator": "Amministratore Directus",
"Disable NIC Offloading (Intel e1000e)": "Disabilita offload NIC (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Disattiva Secure Boot nel firmware UEFI ed esegui di nuovo il programma di installazione. Non è stato modificato nulla.",
"Disable USB/IP server": "Disabilita il server USB/IP",
"Disable autostart on conflicting VM": "Disabilita l'avvio automatico sulla VM in conflitto",
"Disable autostart on one VM to avoid startup conflicts.": "Disabilita l'avvio automatico su una VM per evitare conflitti di avvio.",
"Disable job": "Disabilita job",
"Disable kernel audit messages (optional but recommended):": "Disabilita i messaggi di audit del kernel (facoltativo ma consigliato):",
"Disable onboot on affected VM(s)/CT(s)": "Disabilita onboot sulle VM/CT interessati",
"Disable portmapper/rpcbind": "Disabilita portmapper/rpcbind",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Disabilitare il server e liberare tutti i dispositivi USB gestiti da ProxMenux? Le selezioni salvate verranno mantenute per la prossima attivazione.",
"Disabled": "Disabilitato",
"Disabling High Availability services...": "Disabilitazione dei servizi ad alta disponibilità...",
"Disabling ZFS autotrim applied by ProxMenux...": "Disattivazione di autotrim ZFS applicato da ProxMenux...",
@@ -2199,6 +2205,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Abilita Desktop remoto (RDP) prima di disabilitare il display virtuale.",
"Enable SSD emulation for this disk?": "Abilitare l'emulazione SSD per questo disco?",
"Enable TCP BBR/Fast Open control": "Abilita controllo TCP BBR/Fast Open",
"Enable USB/IP server": "Abilita il server USB/IP",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Abilita la transcodifica VA-API e la conversione dei toni da HDR10/Dolby Vision a SDR",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Abilita VA-API, la codifica hardware e la mappatura dei toni OpenCL in Jellyfin",
"Enable VFIO IOMMU support": "Abilita supporto VFIO IOMMU",
@@ -3212,6 +3219,7 @@
"If pvesm path returned a DEVICE (LVM):": "Se pvesm path ha restituito un DISPOSITIVO (LVM):",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Se pvesm path ha restituito una DIRECTORY (ZFS/BTRFS):",
"If repositories error:": "Se si verificano errori nei repository:",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Se il servizio USB/IP si arresta in modo imprevisto, interrompi la condivisione mentre il dispositivo è ancora collegato per liberarlo. In rari casi solo un riavvio dell'host lo libera.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Se il container era già stato modificato, viene ripristinato il suo backup nativo precedente. Le directory condivise dell'host non vengono ripristinate. I dischi del tentativo fallito vengono rimossi se la pulizia riesce.",
"If the host freezes, remove hostpci entries from": "Se l'host si blocca, rimuovi le voci hostpci da",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If anything fails, the backup is restored.": "Se il canale immagine ha una nuova versione, CT viene arrestato, sottoposto a backup, verificato e sostituito con la nuova immagine per aggiornare il contenitore. Se qualcosa fallisce, il backup viene ripristinato.",
@@ -3406,6 +3414,7 @@
"Install server packages inside CT:": "Installa i pacchetti server nel CT:",
"Install terminal multiplexers": "Installa multiplexer di terminale",
"Install the Edge TPU runtime (libedgetpu1-std)": "Installa il runtime Edge TPU (libedgetpu1-std)",
"Install the USB/IP dependencies from the configured Debian repositories?": "Installare le dipendenze USB/IP dai repository Debian configurati?",
"Install this image?": "Installare questa immagine?",
"Install with Cloud-Init script": "Installa con lo script Cloud-Init",
"Install with ISO from UUP Dump": "Installa con ISO da UUP Dump",
@@ -3596,6 +3605,7 @@
"Invalid check package:": "Pacchetto controllo non valido:",
"Invalid configuration path:": "Percorso configurazione non valido:",
"Invalid consume/export volumes": "Volumi di acquisizione/esportazione non validi",
"Invalid container device GID": "GID del dispositivo del container non valido",
"Invalid container path:": "Percorso nel container non valido:",
"Invalid credential file path:": "Percorso file credenziali non valido:",
"Invalid declarative entrypoint": "Entrypoint dichiarativo non valido",
@@ -3890,9 +3900,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Riga da incollare (una sola riga, incluso il prefisso \"command=...\"):",
"LinkStack": "LinkStack",
"Linux Installation Options": "Opzioni installazione Linux",
"Linux guest setup and migration notes": "Configurazione degli ospiti Linux e note sulla migrazione",
"Linux/Mac path:": "Percorso Linux/Mac:",
"LinuxServer Jellyfin with optional GPU passthrough": "LinuxServer Jellyfin con passthrough GPU opzionale",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB richiede un utente, un database e una password",
"LinuxServer Nextcloud with MariaDB and Redis": "Nextcloud di LinuxServer con MariaDB e Redis",
"List Available Disks": "Elenca i dischi disponibili",
"List IOMMU group mapping": "Elenca la mappatura dei gruppi IOMMU",
"List NVMe devices": "Elenca i dispositivi NVMe",
@@ -4397,6 +4409,7 @@
"Name or part of the option or of the application": "Nome dell'opzione o dell'applicazione, anche parziale",
"Name:": "Nome:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Gli account nominativi richiedono la modalità privata. Arresta il container, imposta public: false in /config/config.js, riavvialo e crea ogni utente con: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "La versione nativa di Borg non è disponibile. Installa il pacchetto borgbackup nativo su questo host per utilizzare i backup Borg.",
"Needed:": "Spazio richiesto:",
"Needs": "Richiede",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Non sono stati trovati né /etc/kernel/cmdline né /etc/default/grub.",
@@ -4472,6 +4485,7 @@
"Next Step Required": "Passaggio successivo obbligatorio",
"Next Steps:": "Passaggi successivi:",
"Next step: stop that VM first, then run": "Passaggio successivo: arresta prima la VM, quindi esegui",
"Nextcloud Web UI": "Interfaccia web di Nextcloud",
"Nextcloud configuration cancelled": "Configurazione di Nextcloud annullata",
"Nextcloud gives you access to all your files wherever you are.": "Nextcloud ti dà accesso a tutti i tuoi file, ovunque ti trovi.",
"Nextcloud volume size in GB": "Dimensione volume Nextcloud in GB",
@@ -4646,6 +4660,7 @@
"No duplicate IP addresses": "Nessun indirizzo IP duplicato",
"No duplicate repositories found": "Nessun repository duplicato trovato",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Dopo il filtraggio SR-IOV non rimane alcun dispositivo controller/NVMe idoneo. Operazione saltata.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Nessun dispositivo USB idoneo trovato. Gli hub, i dispositivi di archiviazione di massa e gli adattatori di rete sono esclusi.",
"No eligible controllers remain after SR-IOV filtering.": "Dopo il filtraggio SR-IOV non rimane alcun controller idoneo.",
"No encryption key is stored on this host. Choose how to set one up:": "Nessuna chiave di crittografia archiviata su questo host. Scegli come configurarne una:",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Nessun disco VM esportabile trovato (CD-ROM/cloud-init esclusi).",
@@ -4730,6 +4745,7 @@
"No self-test log available for": "Nessun registro di autotest disponibile per",
"No server IP or hostname provided.": "Nessun IP del server o nome host fornito.",
"No share access test succeeded with guest authentication.": "Nessun test di accesso alle condivisioni è riuscito con l'autenticazione guest.",
"No shared devices are available on this server.": "Nessun dispositivo condiviso disponibile su questo server.",
"No shared media content.": "Nessun contenuto multimediale condiviso.",
"No shared mount detected. Applying standard local access.": "Nessun montaggio condiviso rilevato. Applicazione dell'accesso locale standard.",
"No shares configured.": "Nessuna condivisione configurata.",
@@ -5128,6 +5144,7 @@
"Path of the Compose file": "Percorso del file Compose",
"Path of the oci-compose file": "Percorso del file oci-compose",
"Path removed:": "Percorso rimosso:",
"Path to the client SSH public key on this server:": "Percorso della chiave pubblica SSH del client su questo server:",
"Path:": "Percorso:",
"Paths applied:": "Percorsi applicati:",
"Paths included in backup": "Percorsi inclusi nel backup",
@@ -5370,6 +5387,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux non li installa. Durante l'installazione ti verranno chiesti i dati di connessione.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux non convalida i contenuti; qualsiasi file di chiavi accettato dal PBS viene accettato qui.",
"ProxMenux files:": "File ProxMenux:",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "ProxMenux genera la password dell'amministratore e la mostra alla fine dell'installazione. Cambiala dopo il primo accesso.",
"ProxMenux logo applied": "Logo ProxMenux applicato",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux funge solo da launcher: una volta avviato lo script, il controllo lascia ProxMenux.",
"ProxMenux saved it locally at:": "ProxMenux lo ha salvato localmente in:",
@@ -5590,6 +5608,7 @@
"Recreation prepared": "Ricreazione preparata",
"Recursive DNS resolver.": "Resolver DNS ricorsivo.",
"Recursive and authoritative DNS server.": "Server DNS ricorsivo e autorevole.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "Il blocco dei file con Redis viene configurato automaticamente. I file di configurazione esistenti di Nextcloud vengono conservati.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "Aggiorna l'indice APT e verifica i repository:",
"Refresh catalog": "Aggiorna catalogo",
@@ -5673,6 +5692,7 @@
"Remove Proxmox NFS storage:": "Rimuovi storage NFS Proxmox:",
"Remove Proxmox iSCSI storage:": "Rimuovi storage iSCSI Proxmox:",
"Remove Secure Gateway? State will be preserved.": "Rimuovere Secure Gateway? Lo stato sarà preservato.",
"Remove a remote USB connection": "Rimuovi una connessione USB remota",
"Remove custom paths": "Rimuovi percorsi personalizzati",
"Remove disk references from affected VM(s)/CT(s) config": "Rimuovi i riferimenti al disco dalle configurazioni VM/CT interessate",
"Remove every registered gasket DKMS version": "Rimuovi ogni versione DKMS di gasket registrata",
@@ -5693,6 +5713,7 @@
"Remove the lock left on a container by an interrupted task": "Rimuovere il blocco lasciato su un container da un’attività interrotta",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Rimuovere le credenziali salvate per {registry}?\nLe app in esecuzione continueranno a funzionare.",
"Remove the unprivileged flag from configuration:": "Rimuovi il flag unprivileged dalla configurazione:",
"Remove this connection and detach its USB device?": "Rimuovere questa connessione e scollegare il relativo dispositivo USB?",
"Remove unused packages and their config": "Rimuovi i pacchetti non utilizzati e la loro configurazione",
"Remove: delete the application and its containers": "Rimuovi: elimina l'applicazione e i suoi container",
"Removed": "RIMOSSO",
@@ -5915,9 +5936,11 @@
"Run mode: Unattended": "Modalità di esecuzione: non presidiata",
"Run now": "Esegui ora",
"Run security audit now": "Esegui subito il controllo di sicurezza",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Esegui il client nella VM Linux che necessita del dispositivo USB. Installa usbip, openssh-client e kmod, poi copia usbip_manager.py nello stesso percorso usato sul server. Configura l'autenticazione tramite chiave e verifica la chiave host del server prima di aggiungere una connessione. Il client usa il kernel dell'ospite e può riconnettersi dopo una migrazione mentre il dispositivo USB fisico rimane sul server. Gli LXC non privilegiati e Home Assistant OS richiedono una configurazione specifica del kernel, dei dispositivi o dei componenti aggiuntivi. Un dispositivo fisico può essere collegato a un solo client alla volta.",
"Run the following inside the VM:": "Esegui quanto segue nella VM:",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Esegui il cambio del canale di rilascio da una sessione SSH o dalla console dell’host Proxmox con:",
"Run the update from an SSH session or the Proxmox host console with:": "Esegui l’aggiornamento da una sessione SSH o dalla console dell’host Proxmox con:",
"Run this menu as root.": "Esegui questo menu come root.",
"Run this operation as root.": "Esegui questa operazione come root.",
"Run upgrade checklist script:": "Esegui lo script di controllo dell’aggiornamento:",
"Running": "In esecuzione",
@@ -6142,6 +6165,7 @@
"Select a new language for the menu:": "Seleziona una nuova lingua per il menu:",
"Select a post-installation script:": "Seleziona uno script post-installazione:",
"Select a pre-configured Linux VM script to execute:": "Seleziona uno script VM Linux preconfigurato da eseguire:",
"Select a saved USB/IP device:": "Seleziona un dispositivo USB/IP salvato:",
"Select a script or action:": "Seleziona uno script o un'azione:",
"Select a share to delete:": "Seleziona una condivisione da eliminare:",
"Select a specific Coral or USB node, not the whole /dev": "Seleziona un nodo Coral o USB specifico, non l’intera directory /dev",
@@ -6219,6 +6243,7 @@
"Select the Linux distribution to install:": "Seleziona la distribuzione Linux da installare:",
"Select the NAS system to install:": "Seleziona il sistema NAS da installare:",
"Select the NVIDIA driver version to install:": "Seleziona la versione del driver NVIDIA da installare:",
"Select the USB device to share:": "Seleziona il dispositivo USB da condividere:",
"Select the VM to add the GPU to:": "Seleziona la VM a cui aggiungere la GPU:",
"Select the VM to which you want to add disks:": "Seleziona la VM a cui aggiungere i dischi:",
"Select the VM where you want to import the disk image:": "Seleziona la VM in cui importare l’immagine disco:",
@@ -6243,6 +6268,7 @@
"Select the parent job to attach to:": "Seleziona l’attività principale a cui collegarsi:",
"Select the paths to restore (one per backup entry):": "Seleziona i percorsi da ripristinare (uno per voce di backup):",
"Select the privileged LXC container to convert:": "Seleziona il container LXC privilegiato da convertire:",
"Select the remote USB device:": "Seleziona il dispositivo USB remoto:",
"Select the storage that will receive the backups:": "Seleziona lo storage di destinazione dei backup:",
"Select the storage volume for disk import:": "Seleziona il volume storage per importare il disco:",
"Select the sync interval (in hours):": "Seleziona l'intervallo di sincronizzazione (in ore):",
@@ -6329,6 +6355,7 @@
"Server IDs to test against, separated by commas (optional)": "ID dei server da usare per i test, separati da virgole (facoltativi)",
"Server IP/hostname is correct": "L'IP/nome host del server è corretto",
"Server IP:": "IP del server:",
"Server SSH port:": "Porta SSH del server:",
"Server is accessible:": "Il server è accessibile:",
"Server is online": "Il server è in linea",
"Server is running": "Il server è in esecuzione",
@@ -6390,6 +6417,7 @@
"Share Access Error": "Errore di accesso alla condivisione",
"Share Name": "Nome condivisione",
"Share Options": "Opzioni di condivisione",
"Share a local USB device": "Condividi un dispositivo USB locale",
"Share added successfully.": "Condivisione aggiunta con successo.",
"Share deleted and Samba service restarted.": "Condivisione eliminata e servizio Samba riavviato.",
"Share exists:": "La condivisione esiste:",
@@ -6397,6 +6425,7 @@
"Share name:": "Nome condivisione:",
"Share not found on server:": "Condivisione non trovata sul server:",
"Share path:": "Percorso condivisione:",
"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 updated successfully.": "Condivisione aggiornata con successo.",
"Share:": "Condivisione:",
"ShareCoveX panel": "Pannello ShareCoveX",
@@ -6665,6 +6694,8 @@
"Still has issues": "Ha ancora problemi",
"Stop - shuts the guest down during backup": "Stop - arresta il guest durante il backup",
"Stop it first and run this option again.": "Interrompilo prima ed esegui nuovamente questa opzione.",
"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 that VM, then choose:": "Arresta quella VM, quindi scegli:",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Arresta il CT, smonta il disco sull’HOST e rimontalo con:",
"Stop the VM/CT before formatting this disk.": "Arresta la VM o il CT prima di formattare questo disco.",
@@ -7020,6 +7051,7 @@
"The container could not be removed automatically:": "Impossibile rimuovere automaticamente il container:",
"The container could not be started:": "Impossibile avviare il container:",
"The container creation does not match the prepared instance": "La creazione del container non corrisponde all'istanza preparata",
"The container device settings changed since the last recorded operation": "Le impostazioni del dispositivo del container sono cambiate dall’ultima operazione registrata",
"The container devices do not match the saved record": "I dispositivi del container non corrispondono al record salvato",
"The container did not stop to update its persistent configuration:": "Il container non si è arrestato per aggiornare la configurazione persistente:",
"The container did not stop; its disks are not touched": "Il container non si è arrestato; i suoi dischi non vengono toccati",
@@ -7301,6 +7333,7 @@
"The record no longer belongs to this operation": "Il record non appartiene più a questa operazione",
"The record of a member was replaced; the assembly is not resumed": "Il record di un componente è stato sostituito; la composizione dello stack non riprende",
"The record or diagnosis could not be completed; no update was run.": "Non è stato possibile completare la registrazione o la diagnosi; non è stato eseguito alcun aggiornamento.",
"The recorded container device GID does not match its configuration": "Il GID registrato del dispositivo del container non corrisponde alla sua configurazione",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "Il recupero non è completo. Rivedere il registro e scegliere \"Recuperare\" di nuovo per questo contenitore nel menu di gestione OCI.",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "Il ripristino non è stato completato. Controllare il log e scegliere di nuovo \"{choice}\" per questo container nel menu di gestione OCI.",
"The registry authentication file is invalid; it was not changed.": "Il file di autenticazione del registro non è valido; non è stato modificato.",
@@ -7368,6 +7401,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "La GPU Intel selezionata ha il supporto per il ripristino non FLR e un sottotipo sconosciuto",
"The selected Intel GPU is currently in power state D3cold": "La GPU Intel selezionata è nello stato di alimentazione D3cold",
"The selected Intel render device does not exist:": "Il dispositivo di rendering Intel selezionato non esiste:",
"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 VM": "La VM selezionata",
"The selected VM is running.": "La VM selezionata è in esecuzione.",
"The selected base folder does not exist and could not be created:": "La cartella di base selezionata non esiste e non è stato possibile crearla:",
@@ -7707,6 +7741,9 @@
"USB drives mounted now:": "Unità USB montate ora:",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "Nodo dispositivo USB o seriale",
"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:",
"UUP Dump script not found.": "Script UUP Dump non trovato.",
"UUp Dump ISO creator Custom": "UUp Dump - Creazione ISO personalizzate",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity è un server domestico gratuito, leggero e facile da usare per fumetti ed ebook. Consente l’accesso ai file ovunque, da tablet, e-reader, telefono o computer.",
@@ -7863,6 +7900,7 @@
"Untrusted or modified NVIDIA hook": "Hook NVIDIA non attendibile o modificato",
"Unused image removed from the cache:": "Immagine inutilizzata rimossa dalla cache:",
"Unused images removed from the cache:": "Immagini non utilizzate rimosse dalla cache:",
"Unused local loopback TCP port for this device:": "Porta TCP locale di loopback inutilizzata per questo dispositivo:",
"Update": "Aggiornamento",
"Update Available": "Aggiornamento disponibile",
"Update Ceph repository (Only if using Ceph):": "Aggiorna repository Ceph (solo se si utilizza Ceph):",
@@ -7961,6 +7999,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Usare un LXC privilegiato per le operazioni server/client NFS.",
"Use a privileged LXC for Samba client/server workflows.": "Usare un LXC privilegiato per le operazioni client/server Samba.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Utilizza un nome host del registro, ad esempio ghcr.io o registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Usa un proxy inverso HTTPS con un certificato attendibile. Aggiorna Nextcloud alla versione principale successiva, senza saltarne nessuna.",
"Use an existing SSH private key file on this host": "Usa un file di chiave privata SSH esistente su questo host",
"Use an existing keyfile": "Usa file di chiavi esistente",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Utilizza un riferimento a un'immagine, ad esempio ghcr.io/owner/app:latest.",
@@ -7974,6 +8013,7 @@
"Use existing filesystem": "Usa file system esistente",
"Use existing: mount without formatting": "Usa esistente: monta senza formattare",
"Use images only if the directory is on suitable storage.": "Usare images solo se la directory è su uno storage adatto.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Usa lo storage gestito per /config e MariaDB. Per /data puoi scegliere una directory dell’host/NAS, che richiede un backup separato.",
"Use mp1/mp2/... for extra mount points.": "Usare mp1/mp2/... per mount aggiuntivi.",
"Use only when unprivileged containers cannot meet your needs": "Usare solo se i container non privilegiati non soddisfano le esigenze",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Usare l’opzione 1 per aggiungere un disco locale (come storage Proxmox e/o mount host).",
@@ -8144,6 +8184,7 @@
"View Disk Storages": "Visualizza storage su disco",
"View Mount Points": "Visualizza punti di montaggio",
"View NFS Mounts (pvesm + fstab)": "Visualizza montaggi NFS (pvesm + fstab)",
"View USB/IP status": "Visualizza lo stato USB/IP",
"View backup metadata": "Visualizza metadati backup",
"View current paths": "Visualizza percorsi correnti",
"View existing folders in /mnt": "Visualizza cartelle esistenti in /mnt",
+41
View File
@@ -246,6 +246,7 @@
"Aborted": "Abortado",
"About to restore": "Prestes a restaurar",
"Absolute directory path to use as backup target:": "caminho absoluto do diretório para usar como destino de backup:",
"Absolute path of the client SSH private key:": "Caminho absoluto da chave privada SSH do cliente:",
"Absolute path to a file or directory you want backed up:": "Caminho absoluto para um arquivo ou diretório do qual deseja fazer backup:",
"Acceleration": "Aceleração",
"Acceleration configuration cancelled": "Configuração da aceleração cancelada",
@@ -619,6 +620,8 @@
"Authentication:": "Autenticação:",
"Authorization failed": "Falha na autorização",
"Authorization successful": "Autorização bem-sucedida",
"Authorize a client SSH public key": "Autorizar a chave pública SSH de um cliente",
"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 this key on the server": "Autorize esta chave no servidor",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Autorizar via 'pct exec' (o servidor Borg é um LXC em um host PVE no qual posso usar SSH)",
"Authorized": "Autorizado",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Utilitários conflitantes removidos",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Conecte dispositivos Zigbee ao MQTT por meio de uma interface web de configuração.",
"Connect a Coral Accelerator and try again.": "Conecte um Coral Accelerator e tente novamente.",
"Connect a remote USB device": "Ligar um dispositivo USB remoto",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Conecte-o ou remova-o do contêiner em Proxmox (Recursos) e execute a recuperação novamente.",
"Connect your devices and users together in your own secure virtual private network.": "Conecte seus dispositivos e usuários em sua própria rede privada virtual segura.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Conecte seus dispositivos a uma rede segura de sobreposição WireGuard® com controles de acesso SSO, MFA e granular.",
@@ -1961,12 +1965,14 @@
"Directus administrator": "Administrador do Directus",
"Disable NIC Offloading (Intel e1000e)": "Desativar descarregamento de NIC (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Desative o Secure Boot no firmware UEFI e execute o instalador novamente. Nada foi alterado.",
"Disable USB/IP server": "Desativar o servidor USB/IP",
"Disable autostart on conflicting VM": "Desative a inicialização automática em VM conflitante",
"Disable autostart on one VM to avoid startup conflicts.": "Desative a inicialização automática em uma VM para evitar conflitos de inicialização.",
"Disable job": "Desativar trabalho",
"Disable kernel audit messages (optional but recommended):": "Desative as mensagens de auditoria do kernel (opcional, mas recomendado):",
"Disable onboot on affected VM(s)/CT(s)": "Desativar inicialização em VM(s)/CT(s) afetados",
"Disable portmapper/rpcbind": "Desativar portmapper/rpcbind",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Desativar o servidor e libertar todos os dispositivos USB geridos pelo ProxMenux? As seleções guardadas serão mantidas para a próxima ativação.",
"Disabled": "Desabilitado",
"Disabling High Availability services...": "Desativando serviços de alta disponibilidade...",
"Disabling ZFS autotrim applied by ProxMenux...": "Desativando o ajuste automático do ZFS aplicado pelo ProxMenux...",
@@ -2199,6 +2205,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Habilite a Área de Trabalho Remota (RDP) antes de desabilitar a exibição virtual.",
"Enable SSD emulation for this disk?": "Ativar emulação SSD para este disco?",
"Enable TCP BBR/Fast Open control": "Habilitar controle TCP BBR/Fast Open",
"Enable USB/IP server": "Ativar o servidor USB/IP",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Habilitar transcodificação VA-API e HDR10/Dolby Vision para mapeamento de tom SDR",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Habilitar VA-API, codificação de hardware e mapeamento de tom OpenCL em Jellyfin",
"Enable VFIO IOMMU support": "Habilitar suporte VFIO IOMMU",
@@ -3212,6 +3219,7 @@
"If pvesm path returned a DEVICE (LVM):": "Se pvesm path devolveu um DISPOSITIVO (LVM):",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Se pvesm path devolveu um DIRETÓRIO (ZFS/BTRFS):",
"If repositories error:": "Se houver erro nos repositórios:",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Se o serviço USB/IP parar inesperadamente, deixe de partilhar o dispositivo enquanto ainda estiver ligado para o libertar. Em casos raros, só um reinício do host o liberta.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Se o contêiner já tiver sido alterado, seu backup nativo anterior será restaurado. Os diretórios de host compartilhados não são revertidos. Os discos da tentativa fracassada serão removidos quando a limpeza for bem-sucedida.",
"If the host freezes, remove hostpci entries from": "Se o host congelar, remova as entradas hostpci de",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If anything fails, the backup is restored.": "Se o canal de imagem tiver uma nova versão, o CT é interrompido, feito backup e verificado e substituído pela nova imagem para atualizar o contêiner. Se algo falhar, o backup será restaurado.",
@@ -3406,6 +3414,7 @@
"Install server packages inside CT:": "Instale pacotes de servidores dentro do CT:",
"Install terminal multiplexers": "Instale multiplexadores de terminal",
"Install the Edge TPU runtime (libedgetpu1-std)": "Instale o tempo de execução do Edge TPU (libedgetpu1-std)",
"Install the USB/IP dependencies from the configured Debian repositories?": "Instalar as dependências USB/IP a partir dos repositórios Debian configurados?",
"Install this image?": "Instalar esta imagem?",
"Install with Cloud-Init script": "Instalar com script Cloud-Init",
"Install with ISO from UUP Dump": "Instalar com ISO do UUP Dump",
@@ -3596,6 +3605,7 @@
"Invalid check package:": "Pacote de verificação inválido:",
"Invalid configuration path:": "Caminho de configuração inválido:",
"Invalid consume/export volumes": "Volumes de consumo/exportação inválidos",
"Invalid container device GID": "GID do dispositivo do contentor inválido",
"Invalid container path:": "Localização do contentor inválida:",
"Invalid credential file path:": "Caminho do arquivo credential inválido:",
"Invalid declarative entrypoint": "Ponto de entrada declarativo inválido",
@@ -3890,9 +3900,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Linha a ser colada (linha única, incluindo o prefixo \"command=...\"):",
"LinkStack": "LinkStack",
"Linux Installation Options": "Opções de instalação do Linux",
"Linux guest setup and migration notes": "Configuração de convidados Linux e notas de migração",
"Linux/Mac path:": "Caminho Linux/Mac:",
"LinuxServer Jellyfin with optional GPU passthrough": "LinuxServer Jellyfin com passagem opcional de GPU",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB requires um usuário, banco de dados e senha",
"LinuxServer Nextcloud with MariaDB and Redis": "Nextcloud da LinuxServer com MariaDB e Redis",
"List Available Disks": "Listar discos disponíveis",
"List IOMMU group mapping": "Listar mapeamento de grupo IOMMU",
"List NVMe devices": "Listar dispositivos NVMe",
@@ -4397,6 +4409,7 @@
"Name or part of the option or of the application": "Nome ou parte da opção ou do aplicativo",
"Name:": "Nome:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Contas nomeadas precisam de modo privado. Pare o contêiner, defina público: false in /config/config.js, inicie-o novamente e crie cada usuário com: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "A versão nativa do Borg não está disponível. Instale o pacote borgbackup nativo neste host para utilizar as cópias de segurança do Borg.",
"Needed:": "Necessário:",
"Needs": "Requer",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Nem /etc/kernel/cmdline nem /etc/default/grub foram encontrados.",
@@ -4472,6 +4485,7 @@
"Next Step Required": "Próxima etapa obrigatória",
"Next Steps:": "Próximas etapas:",
"Next step: stop that VM first, then run": "Próxima etapa: pare a VM primeiro e depois execute",
"Nextcloud Web UI": "Interface web do Nextcloud",
"Nextcloud configuration cancelled": "Configuração do Nextcloud cancelada",
"Nextcloud gives you access to all your files wherever you are.": "O Nextcloud dá-lhe acesso a todos os seus ficheiros onde quer que esteja.",
"Nextcloud volume size in GB": "Tamanho do volume Nextcloud em GB",
@@ -4646,6 +4660,7 @@
"No duplicate IP addresses": "Nenhum endereço IP duplicado",
"No duplicate repositories found": "Nenhum repositório duplicado encontrado",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Nenhum dispositivo Controlador/NVMe qualificado permanece após a filtragem SR-IOV. Pulando.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Não foram encontrados dispositivos USB elegíveis. Os hubs, os dispositivos de armazenamento em massa e os adaptadores de rede estão excluídos.",
"No eligible controllers remain after SR-IOV filtering.": "Nenhum controlador elegível permanece após a filtragem SR-IOV.",
"No encryption key is stored on this host. Choose how to set one up:": "Nenhuma chave de criptografia é armazenada neste host. Escolha como configurar um:",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Nenhum disco VM exportável foi encontrado (CD-ROM/cloud-init foram excluídos).",
@@ -4730,6 +4745,7 @@
"No self-test log available for": "Nenhum registro de autoteste disponível para",
"No server IP or hostname provided.": "Nenhum IP do servidor ou nome de host fornecido.",
"No share access test succeeded with guest authentication.": "Nenhum teste de acesso de compartilhamento foi bem-sucedido com a autenticação de convidado.",
"No shared devices are available on this server.": "Não há dispositivos partilhados disponíveis neste servidor.",
"No shared media content.": "Sem conteúdo de mídia compartilhado.",
"No shared mount detected. Applying standard local access.": "Nenhuma montagem compartilhada detectada. Aplicando acesso local padrão.",
"No shares configured.": "Nenhum compartilhamento configurado.",
@@ -5128,6 +5144,7 @@
"Path of the Compose file": "Localização do ficheiro Compor",
"Path of the oci-compose file": "Caminho do arquivo oci-compose",
"Path removed:": "Caminho removido:",
"Path to the client SSH public key on this server:": "Caminho da chave pública SSH do cliente neste servidor:",
"Path:": "Caminho:",
"Paths applied:": "Caminhos aplicados:",
"Paths included in backup": "Caminhos incluídos no backup",
@@ -5370,6 +5387,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux não os instala. A instalação pergunta como se conectar a eles.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux não valida o conteúdo;qualquer arquivo-chave que seu PBS aceita é aceito aqui.",
"ProxMenux files:": "Arquivos ProxMenux:",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "O ProxMenux gera a senha do administrador e mostra-a no fim da instalação. Altere-a após o primeiro login.",
"ProxMenux logo applied": "Logotipo ProxMenux aplicado",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux atua apenas como um iniciador – assim que o script é iniciado, o controle sai do ProxMenux.",
"ProxMenux saved it locally at:": "ProxMenux salvou localmente em:",
@@ -5590,6 +5608,7 @@
"Recreation prepared": "Recreação preparada",
"Recursive DNS resolver.": "Resolvedor DNS recursivo.",
"Recursive and authoritative DNS server.": "Servidor DNS recursivo e autoritativo.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "O bloqueio de ficheiros com Redis é configurado automaticamente. Os ficheiros de configuração existentes do Nextcloud são preservados.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "Atualize o índice APT e verifique os repositórios:",
"Refresh catalog": "Atualizar catálogo",
@@ -5673,6 +5692,7 @@
"Remove Proxmox NFS storage:": "Remova o armazenamento Proxmox NFS:",
"Remove Proxmox iSCSI storage:": "Remova o armazenamento iSCSI Proxmox:",
"Remove Secure Gateway? State will be preserved.": "Remover Secure Gateway? O estado será preservado.",
"Remove a remote USB connection": "Remover uma ligação USB remota",
"Remove custom paths": "Remover caminhos personalizados",
"Remove disk references from affected VM(s)/CT(s) config": "Remover referências de disco da configuração de VM(s)/CT(s) afetadas",
"Remove every registered gasket DKMS version": "Remover todas as versões DKMS de gasket registadas",
@@ -5693,6 +5713,7 @@
"Remove the lock left on a container by an interrupted task": "Remover o bloqueio deixado num contentor por uma tarefa interrompida",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Remover as credenciais salvas para {registry}?\nOs aplicativos em execução continuarão funcionando.",
"Remove the unprivileged flag from configuration:": "Remova o sinalizador sem privilégios da configuração:",
"Remove this connection and detach its USB device?": "Remover esta ligação e desligar o respetivo dispositivo USB?",
"Remove unused packages and their config": "Remova pacotes não utilizados e suas configurações",
"Remove: delete the application and its containers": "Remover: apagar o aplicativo e seus recipientes",
"Removed": "Removido",
@@ -5915,9 +5936,11 @@
"Run mode: Unattended": "Modo de execução: Autônomo",
"Run now": "Executar agora",
"Run security audit now": "Execute a auditoria de segurança agora",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Execute o cliente na VM Linux que precisa do dispositivo USB. Instale usbip, openssh-client e kmod e copie usbip_manager.py para o mesmo caminho usado no servidor. Configure a autenticação por chave e verifique a chave de anfitrião do servidor antes de adicionar uma ligação. O cliente usa o kernel do convidado e pode voltar a ligar-se após uma migração enquanto o dispositivo USB físico permanece no servidor. Os LXC sem privilégios e o Home Assistant OS precisam de uma configuração própria do kernel, dos dispositivos ou dos complementos. Um dispositivo físico só pode estar ligado a um cliente de cada vez.",
"Run the following inside the VM:": "Execute o seguinte dentro da VM:",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "execute a opção de canal de lançamento a partir de uma sessão SSH ou do console do host Proxmox com:",
"Run the update from an SSH session or the Proxmox host console with:": "execute a atualização a partir de uma sessão SSH ou do console do host Proxmox com:",
"Run this menu as root.": "Execute este menu como root.",
"Run this operation as root.": "Execute esta operação como root.",
"Run upgrade checklist script:": "Execute o script da lista de verificação de atualização:",
"Running": "Correndo",
@@ -6142,6 +6165,7 @@
"Select a new language for the menu:": "Selecione um novo idioma para o menu:",
"Select a post-installation script:": "Selecione um script pós-instalação:",
"Select a pre-configured Linux VM script to execute:": "Selecione um script de VM Linux pré-configurado para executar:",
"Select a saved USB/IP device:": "Selecione um dispositivo USB/IP guardado:",
"Select a script or action:": "Selecione um script ou ação:",
"Select a share to delete:": "Selecione um compartilhamento para excluir:",
"Select a specific Coral or USB node, not the whole /dev": "Selecione um nó Coral ou USB específico, não o /dev inteiro",
@@ -6219,6 +6243,7 @@
"Select the Linux distribution to install:": "Selecione a distribuição Linux para instalar:",
"Select the NAS system to install:": "Selecione o sistema NAS para instalar:",
"Select the NVIDIA driver version to install:": "Selecione a versão do driver NVIDIA para instalar:",
"Select the USB device to share:": "Selecione o dispositivo USB a partilhar:",
"Select the VM to add the GPU to:": "Selecione a VM à qual adicionar a GPU:",
"Select the VM to which you want to add disks:": "Selecione a VM à qual você deseja adicionar discos:",
"Select the VM where you want to import the disk image:": "Selecione a VM para a qual deseja importar a imagem de disco:",
@@ -6243,6 +6268,7 @@
"Select the parent job to attach to:": "Selecione o trabalho pai ao qual deseja anexar:",
"Select the paths to restore (one per backup entry):": "Selecione os caminhos para restaurar (um por entrada de backup):",
"Select the privileged LXC container to convert:": "Selecione o contêiner LXC privilegiado para converter:",
"Select the remote USB device:": "Selecione o dispositivo USB remoto:",
"Select the storage that will receive the backups:": "Selecione o armazenamento que receberá os backups:",
"Select the storage volume for disk import:": "Selecione o volume de armazenamento para importação de disco:",
"Select the sync interval (in hours):": "Selecione o intervalo de sincronização (em horas):",
@@ -6329,6 +6355,7 @@
"Server IDs to test against, separated by commas (optional)": "IDs de servidor para testar, separados por vírgulas (opcional)",
"Server IP/hostname is correct": "O IP/nome do host do servidor está correto",
"Server IP:": "IP do servidor:",
"Server SSH port:": "Porta SSH do servidor:",
"Server is accessible:": "O servidor está acessível:",
"Server is online": "O servidor está on-line",
"Server is running": "O servidor está em execução",
@@ -6390,6 +6417,7 @@
"Share Access Error": "Erro de acesso ao compartilhamento",
"Share Name": "Compartilhar nome",
"Share Options": "Opções de compartilhamento",
"Share a local USB device": "Partilhar um dispositivo USB local",
"Share added successfully.": "Compartilhamento adicionado com sucesso.",
"Share deleted and Samba service restarted.": "Compartilhamento excluído e serviço Samba reiniciado.",
"Share exists:": "Existe compartilhamento:",
@@ -6397,6 +6425,7 @@
"Share name:": "Nome do compartilhamento:",
"Share not found on server:": "Compartilhamento não encontrado no servidor:",
"Share path:": "Compartilhar caminho:",
"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 updated successfully.": "Compartilhamento atualizado com sucesso.",
"Share:": "Compartilhar:",
"ShareCoveX panel": "Painel ShareCoveX",
@@ -6665,6 +6694,8 @@
"Still has issues": "Ainda tem problemas",
"Stop - shuts the guest down during backup": "Parar - desliga o convidado durante o backup",
"Stop it first and run this option again.": "Pare primeiro e execute esta opção novamente.",
"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 that VM, then choose:": "Pare esse VM e escolha:",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Pare o CT, desmonte o disco no HOST e remonte com:",
"Stop the VM/CT before formatting this disk.": "Pare a VM/CT antes de formatar este disco.",
@@ -7020,6 +7051,7 @@
"The container could not be removed automatically:": "O recipiente não pôde ser removido automaticamente:",
"The container could not be started:": "Não foi possível iniciar o recipiente:",
"The container creation does not match the prepared instance": "A criação do recipiente não corresponde à instância preparada",
"The container device settings changed since the last recorded operation": "As definições do dispositivo do contentor mudaram desde a última operação registada",
"The container devices do not match the saved record": "Os dispositivos do contentor não correspondem ao registo gravado",
"The container did not stop to update its persistent configuration:": "O recipiente não parou para atualizar sua configuração persistente:",
"The container did not stop; its disks are not touched": "O recipiente não parou; os seus discos não são tocados",
@@ -7301,6 +7333,7 @@
"The record no longer belongs to this operation": "O registro já não pertence a esta operation",
"The record of a member was replaced; the assembly is not resumed": "O registro de um membro foi substituído; a assembléia não é retomada",
"The record or diagnosis could not be completed; no update was run.": "O registro ou diagnóstico não pôde ser concluído; nenhuma atualização foi realizada.",
"The recorded container device GID does not match its configuration": "O GID registado do dispositivo do contentor não corresponde à configuração",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "A recuperação não terminou. Reveja o log e escolha \"Recover\" novamente para este recipiente no menu de gerenciamento OCI.",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "A recuperação não foi concluída. Revise o log e escolha \"{choice}\" novamente para este contêiner no menu de gerenciamento OCI.",
"The registry authentication file is invalid; it was not changed.": "O arquivo de autenticação do registro é inválido;não foi alterado.",
@@ -7368,6 +7401,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "A GPU Intel selecionada tem suporte para redefinição não FLR e subtipo desconhecido",
"The selected Intel GPU is currently in power state D3cold": "A GPU Intel selecionada está atualmente no estado de energia D3cold",
"The selected Intel render device does not exist:": "O dispositivo de renderização Intel selecionado não existe:",
"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 VM": "A VM selecionada",
"The selected VM is running.": "A VM selecionada está em execução.",
"The selected base folder does not exist and could not be created:": "A pasta base selecionada não existe e não pôde ser criada:",
@@ -7707,6 +7741,9 @@
"USB drives mounted now:": "unidades USB montadas agora:",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "nó de dispositivo USB ou serial",
"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:",
"UUP Dump script not found.": "Script de despejo UUP não encontrado.",
"UUp Dump ISO creator Custom": "Criador UUp Dump ISO personalizado",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity é um servidor doméstico gratuito, leve e fácil de usar para seus quadrinhos e ebooks. Use-o para acessar seus arquivos de qualquer lugar, com um tablet, um e-leitor, um telefone ou um computador.",
@@ -7863,6 +7900,7 @@
"Untrusted or modified NVIDIA hook": "Gancho NVIDIA não confiável ou modificado",
"Unused image removed from the cache:": "Imagem não usada removida da cache:",
"Unused images removed from the cache:": "Imagens não usadas removidas da cache:",
"Unused local loopback TCP port for this device:": "Porta TCP local de loopback livre para este dispositivo:",
"Update": "Atualizar",
"Update Available": "atualização disponível",
"Update Ceph repository (Only if using Ceph):": "Atualize o repositório do Ceph (somente se estiver usando o Ceph):",
@@ -7961,6 +7999,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Use um LXC privilegiado para fluxos de trabalho de servidor/cliente NFS.",
"Use a privileged LXC for Samba client/server workflows.": "Use um LXC privilegiado para fluxos de trabalho cliente/servidor Samba.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Use um nome de host de registro, por exemplo ghcr.io ou registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Use um proxy inverso HTTPS com um certificado de confiança. Atualize o Nextcloud para a versão principal seguinte, sem saltar nenhuma.",
"Use an existing SSH private key file on this host": "Use um arquivo de chave privada SSH existente neste host",
"Use an existing keyfile": "Use um arquivo-chave existente",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Use uma referência de imagem, por exemplo ghcr.io/owner/app:latest.",
@@ -7974,6 +8013,7 @@
"Use existing filesystem": "Use o sistema de arquivos existente",
"Use existing: mount without formatting": "Use existente: monte sem formatação",
"Use images only if the directory is on suitable storage.": "Use imagens somente se o diretório estiver em um armazenamento adequado.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Use armazenamento gerido para /config e MariaDB. Para /data pode escolher um diretório do host/NAS, que precisa de uma cópia de segurança separada.",
"Use mp1/mp2/... for extra mount points.": "Use mp1/mp2/... para pontos de montagem extras.",
"Use only when unprivileged containers cannot meet your needs": "Use somente quando contêineres sem privilégios não atenderem às suas necessidades",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Use a opção 1 para adicionar um disco local (como armazenamento Proxmox e/ou como montagem de host).",
@@ -8144,6 +8184,7 @@
"View Disk Storages": "Ver armazenamentos em disco",
"View Mount Points": "Ver pontos de montagem",
"View NFS Mounts (pvesm + fstab)": "Ver montagens NFS (pvesm + fstab)",
"View USB/IP status": "Ver estado USB/IP",
"View backup metadata": "Ver metadados de backup",
"View current paths": "Ver caminhos atuais",
"View existing folders in /mnt": "Veja as pastas existentes em /mnt",
+41
View File
@@ -246,6 +246,7 @@
"Aborted": "Zrušené",
"About to restore": "Chystá sa obnova",
"Absolute directory path to use as backup target:": "Absolútna cesta k priečinku, ktorý sa má použiť ako cieľ záloh:",
"Absolute path of the client SSH private key:": "Absolútna cesta k súkromnému SSH kľúču klienta:",
"Absolute path to a file or directory you want backed up:": "Absolútna cesta k súboru alebo priečinku, ktorý chcete zálohovať:",
"Acceleration": "Zrýchlenie",
"Acceleration configuration cancelled": "Konfigurácia zrýchlenia zrušená",
@@ -619,6 +620,8 @@
"Authentication:": "Prihlásenie:",
"Authorization failed": "Autorizácia zlyhala",
"Authorization successful": "Autorizácia úspešná",
"Authorize a client SSH public key": "Povoliť verejný SSH kľúč klienta",
"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 this key on the server": "Autorizovať tento kľúč na serveri",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Autorizovať cez 'pct exec' (Borg server je LXC na PVE hostovi, kam sa viem pripojiť cez SSH)",
"Authorized": "Autorizované",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Konfliktné nástroje odstránené",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Pripojte zariadenia Zigbee k MQTT prostredníctvom webového rozhrania na nastavenie.",
"Connect a Coral Accelerator and try again.": "Pripojte Coral Accelerator a skúste to znova.",
"Connect a remote USB device": "Pripojiť vzdialené USB zariadenie",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Pripojte zariadenie alebo ho odstráňte z konfigurácie kontajnera v Proxmoxe (Zdroje) a registráciu zopakujte.",
"Connect your devices and users together in your own secure virtual private network.": "Spojte svoje zariadenia a užívateľov vo vlastnej zabezpečenej virtuálnej súkromnej sieti.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Pripojte svoje zariadenia do zabezpečenej prekryvnej siete založenej na WireGuard® s ovládacími prvkami SSO, MFA a granulovaného prístupu.",
@@ -1961,12 +1965,14 @@
"Directus administrator": "Správca Directus",
"Disable NIC Offloading (Intel e1000e)": "Vypnúť NIC offloading (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Vypnite Secure Boot vo firmvéri UEFI a spustite inštalátor znova. Nič sa nezmenilo.",
"Disable USB/IP server": "Zakázať server USB/IP",
"Disable autostart on conflicting VM": "Vypnúť automatický štart na konfliktnej VM",
"Disable autostart on one VM to avoid startup conflicts.": "Na jednej VM vypnite automatický štart, aby nevznikol konflikt pri spúšťaní.",
"Disable job": "Zakázať úlohu",
"Disable kernel audit messages (optional but recommended):": "Vypnite audit správy kernelu (voliteľné, ale odporúčané):",
"Disable onboot on affected VM(s)/CT(s)": "Vypnúť onboot na dotknutých VM/CT",
"Disable portmapper/rpcbind": "Vypnúť portmapper/rpcbind",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Zakázať server a uvoľniť všetky USB zariadenia spravované ProxMenuxom? Uložený výber sa zachová pre ďalšie povolenie.",
"Disabled": "Vypnuté",
"Disabling High Availability services...": "Vypínam služby High Availability...",
"Disabling ZFS autotrim applied by ProxMenux...": "Vypínam ZFS autotrim, ktorý nastavil ProxMenux...",
@@ -2199,6 +2205,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Pred vypnutím virtuálneho zobrazenia zapnite Vzdialenú plochu (RDP).",
"Enable SSD emulation for this disk?": "Zapnúť pre tento disk emuláciu SSD?",
"Enable TCP BBR/Fast Open control": "Zapnúť TCP BBR/Fast Open",
"Enable USB/IP server": "Povoliť server USB/IP",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Povoliť prevod videa cez VA-API a mapovanie tónov HDR10/Dolby Vision na SDR",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Povoliť VA-API, hardvérové kódovanie a mapovanie tónov pomocou OpenCL v Jellyfine",
"Enable VFIO IOMMU support": "Zapnúť podporu VFIO/IOMMU",
@@ -3211,6 +3218,7 @@
"If pvesm path returned a DEVICE (LVM):": "Ak pvesm path vrátil ZARIADENIE (LVM):",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Ak pvesm path vrátil PRIEČINOK (ZFS/BTRFS):",
"If repositories error:": "Ak zlyhajú repozitáre:",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Ak sa služba USB/IP neočakávane zastaví, ukončite zdieľanie, kým je zariadenie ešte pripojené, aby sa uvoľnilo. V zriedkavých prípadoch ho uvoľní iba reštart hosta.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Ak už bol kontajner zmenený, obnoví sa jeho predchádzajúca natívna záloha. Zdieľané hostiteľské adresáre sa nevrátia späť. Disky neúspešného pokusu sa po úspešnom vyčistení odstránia.",
"If the host freezes, remove hostpci entries from": "Ak server zamrzne, odstráňte položky hostpci z",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If anything fails, the backup is restored.": "Ak má kanál obrázkov novú verziu, CT sa zastaví, zálohuje a overí a nahradí sa novým obrázkom, aby sa aktualizoval kontajner. Ak niečo zlyhá, záloha sa obnoví.",
@@ -3406,6 +3414,7 @@
"Install server packages inside CT:": "Nainštalovať serverové balíky vo vnútri CT:",
"Install terminal multiplexers": "Nainštalovať terminálové multiplexery",
"Install the Edge TPU runtime (libedgetpu1-std)": "Nainštaluje Edge TPU runtime (libedgetpu1-std)",
"Install the USB/IP dependencies from the configured Debian repositories?": "Nainštalovať závislosti USB/IP z nakonfigurovaných repozitárov Debianu?",
"Install this image?": "Nainštalovať tento obraz?",
"Install with Cloud-Init script": "Inštalovať cez Cloud-Init skript",
"Install with ISO from UUP Dump": "Inštalovať z ISO cez UUP Dump",
@@ -3596,6 +3605,7 @@
"Invalid check package:": "Neplatný kontrolný balík:",
"Invalid configuration path:": "Neplatná konfiguračná dráha:",
"Invalid consume/export volumes": "Neplatné dátové zväzky consume a export",
"Invalid container device GID": "Neplatné GID zariadenia v kontajneri",
"Invalid container path:": "Neplatná trasa kontajnera:",
"Invalid credential file path:": "Neplatná cesta súboru s prihlasovacími údajmi:",
"Invalid declarative entrypoint": "Neplatný deklaratívny vstupný bod",
@@ -3890,9 +3900,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Riadok na vloženie (jeden riadok vrátane predpony \"command=...\"):",
"LinkStack": "LinkStack",
"Linux Installation Options": "Možnosti inštalácie Linuxu",
"Linux guest setup and migration notes": "Nastavenie hostí Linux a poznámky k migrácii",
"Linux/Mac path:": "Cesta pre Linux/Mac:",
"LinuxServer Jellyfin with optional GPU passthrough": "LinuxServer Jellyfin s voliteľným GPU priechodom",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB vyžaduje používateľa, databázu a heslo",
"LinuxServer Nextcloud with MariaDB and Redis": "Nextcloud od LinuxServer s MariaDB a Redis",
"List Available Disks": "Zobraziť dostupné disky",
"List IOMMU group mapping": "Zobraziť mapovanie IOMMU skupín",
"List NVMe devices": "Zobraziť NVMe zariadenia",
@@ -4397,6 +4409,7 @@
"Name or part of the option or of the application": "Názov alebo časť možnosti alebo aplikácie",
"Name:": "Názov:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Používateľské účty vyžadujú súkromný režim. Zastavte kontajner, v /config/config.js nastavte public: false, znova ho spustite a každého používateľa vytvorte príkazom: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "Natívna verzia Borg nie je dostupná. Ak chcete používať zálohy Borg, nainštalujte na tento hostiteľ natívny balík borgbackup.",
"Needed:": "Potrebné:",
"Needs": "Vyžaduje",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Nenašiel sa /etc/kernel/cmdline ani /etc/default/grub.",
@@ -4472,6 +4485,7 @@
"Next Step Required": "Je potrebný ďalší krok",
"Next Steps:": "Ďalšie kroky:",
"Next step: stop that VM first, then run": "Ďalší krok: najprv zastavte tú VM, potom spustite",
"Nextcloud Web UI": "Webové rozhranie Nextcloud",
"Nextcloud configuration cancelled": "Konfigurácia Nextcloud zrušená",
"Nextcloud gives you access to all your files wherever you are.": "Nextcloud vám umožní prístup ku všetkým súborom kdekoľvek ste.",
"Nextcloud volume size in GB": "Veľkosť dátového zväzku Nextcloud v GB",
@@ -4646,6 +4660,7 @@
"No duplicate IP addresses": "Nenašli sa duplicitné IP adresy",
"No duplicate repositories found": "Nenašli sa žiadne duplicitné repozitáre",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Po odfiltrovaní SR-IOV nezostali žiadne vhodné Controller/NVMe zariadenia. Preskakuje sa.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Nenašli sa žiadne vhodné USB zariadenia. Rozbočovače, veľkokapacitné úložiská a sieťové adaptéry sú vylúčené.",
"No eligible controllers remain after SR-IOV filtering.": "Po odfiltrovaní SR-IOV nezostali žiadne vhodné controllery.",
"No encryption key is stored on this host. Choose how to set one up:": "Na tomto hostovi nie je uložený žiadny šifrovací kľúč. Vyberte, ako ho nastaviť:",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Nenašli sa žiadne exportovateľné VM disky (CD-ROM/cloud-init sú vylúčené).",
@@ -4730,6 +4745,7 @@
"No self-test log available for": "Nie je dostupný log samo-testu pre",
"No server IP or hostname provided.": "Nebola zadaná IP adresa ani názov servera.",
"No share access test succeeded with guest authentication.": "S overením hosťa nebol úspešný žiadny test prístupu k zdieľaniu.",
"No shared devices are available on this server.": "Na tomto serveri nie sú k dispozícii žiadne zdieľané zariadenia.",
"No shared media content.": "Žiadny spoločný mediálny obsah.",
"No shared mount detected. Applying standard local access.": "Nenašlo sa zdieľané pripojenie. Nastavujem bežný lokálny prístup.",
"No shares configured.": "Nie sú nastavené žiadne zdieľania.",
@@ -5130,6 +5146,7 @@
"Path of the Compose file": "Cesta k súboru Compose",
"Path of the oci-compose file": "Cesta k súboru oci-compose",
"Path removed:": "Cesta bola odstránená:",
"Path to the client SSH public key on this server:": "Cesta k verejnému SSH kľúču klienta na tomto serveri:",
"Path:": "Cesta:",
"Paths applied:": "Použité cesty:",
"Paths included in backup": "Cesty zahrnuté v zálohe",
@@ -5372,6 +5389,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux ich neinštaluje. Inštalácia sa pýta, ako sa k nim pripojiť.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux nekontroluje obsah; prijme sa každý keyfile, ktorý prijme vaše PBS.",
"ProxMenux files:": "Súbory ProxMenux:",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "ProxMenux vygeneruje heslo správcu a zobrazí ho na konci inštalácie. Po prvom prihlásení ho zmeňte.",
"ProxMenux logo applied": "Logo ProxMenux bolo použité",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux tu slúži iba ako spúšťač - po spustení skriptu už riadenie neprebieha cez ProxMenux.",
"ProxMenux saved it locally at:": "ProxMenux ju uložil lokálne v:",
@@ -5592,6 +5610,7 @@
"Recreation prepared": "Pripravené rekreácie",
"Recursive DNS resolver.": "Rekurzívny DNS resolver.",
"Recursive and authoritative DNS server.": "Rekurzívny a autoritatívny DNS server.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "Zamykanie súborov pomocou Redis sa nastaví automaticky. Existujúce konfiguračné súbory Nextcloud sa zachovajú.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "Obnovte APT index a overte repozitáre:",
"Refresh catalog": "Aktualizovať katalóg",
@@ -5675,6 +5694,7 @@
"Remove Proxmox NFS storage:": "Odstrániť NFS úložisko z Proxmoxu:",
"Remove Proxmox iSCSI storage:": "Odstrániť iSCSI úložisko z Proxmoxu:",
"Remove Secure Gateway? State will be preserved.": "Odstrániť Secure Gateway? Stav bude zachovaný.",
"Remove a remote USB connection": "Odstrániť vzdialené USB pripojenie",
"Remove custom paths": "Odstrániť vlastné cesty",
"Remove disk references from affected VM(s)/CT(s) config": "Odstrániť referencie na disk z nastavení dotknutých VM/CT",
"Remove every registered gasket DKMS version": "Odstrániť všetky registrované verzie gasket DKMS",
@@ -5695,6 +5715,7 @@
"Remove the lock left on a container by an interrupted task": "Odstrániť zámok, ktorý na kontajneri zanechala prerušená úloha",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Odstrániť uložené prihlasovacie údaje pre {registry}?\nSpustené aplikácie budú naďalej fungovať.",
"Remove the unprivileged flag from configuration:": "Odstrániť príznak neprivilegovaného kontajnera z nastavenia:",
"Remove this connection and detach its USB device?": "Odstrániť toto pripojenie a odpojiť jeho USB zariadenie?",
"Remove unused packages and their config": "Odstrániť nepoužívané balíky aj ich nastavenia",
"Remove: delete the application and its containers": "Odstrániť: zmazať aplikáciu a jej kontajnery",
"Removed": "Odstránené",
@@ -5917,9 +5938,11 @@
"Run mode: Unattended": "Režim spustenia: bez zásahu",
"Run now": "Bež teraz",
"Run security audit now": "Spustiť bezpečnostný audit teraz",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Klienta spustite vo virtuálnom stroji Linux, ktorý potrebuje USB zariadenie. Nainštalujte usbip, openssh-client a kmod a potom skopírujte usbip_manager.py na rovnakú cestu ako na serveri. Pred pridaním pripojenia nastavte overovanie kľúčom a overte hostiteľský kľúč servera. Klient používa jadro hosťa a po migrácii sa môže znova pripojiť, zatiaľ čo fyzické USB zariadenie zostáva na serveri. Neprivilegované LXC a Home Assistant OS vyžadujú vlastné nastavenie jadra, zariadení alebo doplnkov. Fyzické zariadenie môže byť pripojené iba k jednému klientovi naraz.",
"Run the following inside the VM:": "Vo vnútri VM spustite:",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Prepnutie vetvy vydania spustite cez SSH alebo priamo z konzoly Proxmox servera pomocou:",
"Run the update from an SSH session or the Proxmox host console with:": "Spustite aktualizáciu zo SSH relácie alebo z konzoly Proxmox hosta pomocou:",
"Run this menu as root.": "Túto ponuku spustite ako root.",
"Run this operation as root.": "Túto operáciu spustite ako root.",
"Run upgrade checklist script:": "Spustite kontrolný skript aktualizácie:",
"Running": "Beží",
@@ -6144,6 +6167,7 @@
"Select a new language for the menu:": "Vyberte nový jazyk menu:",
"Select a post-installation script:": "Vyberte skript po inštalácii:",
"Select a pre-configured Linux VM script to execute:": "Vyberte pripravený skript pre Linux VM, ktorý sa má spustiť:",
"Select a saved USB/IP device:": "Vyberte uložené zariadenie USB/IP:",
"Select a script or action:": "Vyberte skript alebo akciu:",
"Select a share to delete:": "Vyberte zdieľanie na vymazanie:",
"Select a specific Coral or USB node, not the whole /dev": "Vyberte špecifický Coral alebo USB uzol, nie celý /dev",
@@ -6221,6 +6245,7 @@
"Select the Linux distribution to install:": "Vyberte Linux distribúciu na inštaláciu:",
"Select the NAS system to install:": "Vyberte NAS systém na inštaláciu:",
"Select the NVIDIA driver version to install:": "Vyberte verziu ovládača NVIDIA na inštaláciu:",
"Select the USB device to share:": "Vyberte USB zariadenie na zdieľanie:",
"Select the VM to add the GPU to:": "Vyberte VM, do ktorej sa má GPU pridať:",
"Select the VM to which you want to add disks:": "Vyberte VM, do ktorej chcete pridať disky:",
"Select the VM where you want to import the disk image:": "Vyberte VM, do ktorej chcete importovať diskový obraz:",
@@ -6245,6 +6270,7 @@
"Select the parent job to attach to:": "Vyberte nadradenú úlohu, ku ktorej sa má pripojiť:",
"Select the paths to restore (one per backup entry):": "Vyberte cesty na obnovu (jedna za každú položku zálohy):",
"Select the privileged LXC container to convert:": "Vyberte privilegovaný LXC kontajner na prevod:",
"Select the remote USB device:": "Vyberte vzdialené USB zariadenie:",
"Select the storage that will receive the backups:": "Vyberte úložisko, ktoré bude prijímať zálohy:",
"Select the storage volume for disk import:": "Vyberte úložisko pre import disku:",
"Select the sync interval (in hours):": "Vyberte interval synchronizácie (v hodinách):",
@@ -6331,6 +6357,7 @@
"Server IDs to test against, separated by commas (optional)": "ID serverov, proti ktorým sa majú spustiť testy (voliteľné)",
"Server IP/hostname is correct": "IP/hostname servera je správny",
"Server IP:": "IP servera:",
"Server SSH port:": "SSH port servera:",
"Server is accessible:": "Server je dostupný:",
"Server is online": "Server je online",
"Server is running": "Server beží",
@@ -6392,6 +6419,7 @@
"Share Access Error": "Chyba prístupu k zdieľaniu",
"Share Name": "Názov zdieľania",
"Share Options": "Možnosti zdieľania",
"Share a local USB device": "Zdieľať miestne USB zariadenie",
"Share added successfully.": "Zdieľanie bolo úspešne pridané.",
"Share deleted and Samba service restarted.": "Zdieľanie bolo vymazané a služba Samba reštartovaná.",
"Share exists:": "Zdieľanie existuje:",
@@ -6399,6 +6427,7 @@
"Share name:": "Názov zdieľania:",
"Share not found on server:": "Zdieľanie sa na serveri nenašlo:",
"Share path:": "Cesta zdieľania:",
"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 updated successfully.": "Zdieľanie bolo úspešne aktualizované.",
"Share:": "Zdieľanie:",
"ShareCoveX panel": "Panel ShareCoveX",
@@ -6667,6 +6696,8 @@
"Still has issues": "Stále má problémy",
"Stop - shuts the guest down during backup": "Stop – vypne hosťa počas zálohovania",
"Stop it first and run this option again.": "Najprv ju zastavte a potom túto možnosť spustite znova.",
"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 that VM, then choose:": "Zastavte VM a potom vyberte:",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Zastavte CT, odpojte disk na HOSTE a pripojte ho znova pomocou:",
"Stop the VM/CT before formatting this disk.": "Pred formátovaním tohto disku zastavte VM/CT.",
@@ -7021,6 +7052,7 @@
"The container could not be removed automatically:": "Kontajner sa nepodarilo automaticky odstrániť:",
"The container could not be started:": "Kontajner sa nepodarilo spustiť:",
"The container creation does not match the prepared instance": "Vytvorenie kontajnera nezodpovedá pripravenej inštancii",
"The container device settings changed since the last recorded operation": "Nastavenia zariadenia v kontajneri sa od poslednej zaznamenanej operácie zmenili",
"The container devices do not match the saved record": "Zariadenie kontajnera nezodpovedá uloženému záznamu",
"The container did not stop to update its persistent configuration:": "Kontajner sa nezastavil na aktualizáciu svojich trvalých nastavení:",
"The container did not stop; its disks are not touched": "Kontajner sa nezastavil; jeho disky nie sú dotknuté",
@@ -7302,6 +7334,7 @@
"The record no longer belongs to this operation": "Záznam už nepatrí tejto operácii",
"The record of a member was replaced; the assembly is not resumed": "Záznam kontajnera bol nahradený; zostavovanie nebude pokračovať",
"The record or diagnosis could not be completed; no update was run.": "Záznam alebo diagnóza nemohla byť dokončená; žiadna aktualizácia nebola spustená.",
"The recorded container device GID does not match its configuration": "Zaznamenané GID zariadenia v kontajneri sa nezhoduje s jeho konfiguráciou",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "Obnova sa nedokončila. Skontrolujte záznam a v ponuke Správca OCI aplikácií pri tomto kontajneri znova vyberte \"Obnoviť\".",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "Obnova sa nedokončila. Skontrolujte záznam a v ponuke Správca OCI aplikácií pri tomto kontajneri znova vyberte \"{choice}\".",
"The registry authentication file is invalid; it was not changed.": "Súbor s prihlasovacími údajmi registrov je neplatný; nebol zmenený.",
@@ -7369,6 +7402,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "Vybraná Intel GPU má reset podporu mimo FLR a neznámy podtyp",
"The selected Intel GPU is currently in power state D3cold": "Vybraná Intel GPU je aktuálne v napájacom stave D3cold",
"The selected Intel render device does not exist:": "Vybrané zariadenie Intel render neexistuje:",
"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 VM": "Vybraná VM",
"The selected VM is running.": "Vybraná VM beží.",
"The selected base folder does not exist and could not be created:": "Vybraný základný priečinok neexistuje a nepodarilo sa ho vytvoriť:",
@@ -7707,6 +7741,9 @@
"USB drives mounted now:": "Aktuálne pripojené USB disky:",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "Uzol USB alebo sériového zariadenia",
"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:",
"UUP Dump script not found.": "Skript UUP Dump sa nenašiel.",
"UUp Dump ISO creator Custom": "Vlastný tvorca ISO cez UUP Dump",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity je bezplatný, ľahký a ľahko použiteľný domáci server pre vaše komiksy a ebooky. Použite ho na prístup k súborom odkiaľkoľvek, s tabletom, e-čítačkou, telefónom alebo počítačom.",
@@ -7863,6 +7900,7 @@
"Untrusted or modified NVIDIA hook": "Nedôveryhodný alebo zmenený spúšťací skript NVIDIA",
"Unused image removed from the cache:": "Nepoužívaný obraz odstránený z vyrovnávacej pamäte:",
"Unused images removed from the cache:": "Nepoužívané obrazy odstránené z vyrovnávacej pamäte:",
"Unused local loopback TCP port for this device:": "Voľný miestny TCP port spätnej slučky pre toto zariadenie:",
"Update": "Aktualizácia",
"Update Available": "Dostupná aktualizácia",
"Update Ceph repository (Only if using Ceph):": "Aktualizujte Ceph repozitár (iba ak používate Ceph):",
@@ -7961,6 +7999,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Pre NFS server/klient používajte privilegovaný LXC.",
"Use a privileged LXC for Samba client/server workflows.": "Pre Samba klient/server používajte privilegovaný LXC.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Použite názov hostiteľa registra, napríklad ghcr.io alebo registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Použite reverzný proxy server HTTPS s dôveryhodným certifikátom. Nextcloud aktualizujte vždy na nasledujúcu hlavnú verziu, žiadnu nepreskakujte.",
"Use an existing SSH private key file on this host": "Použiť existujúci súbor súkromného SSH kľúča na tomto hostovi",
"Use an existing keyfile": "Použiť existujúci keyfile",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Použite odkaz na obraz, napríklad ghcr.io/owner/app:latest.",
@@ -7974,6 +8013,7 @@
"Use existing filesystem": "Použiť existujúci súborový systém",
"Use existing: mount without formatting": "Použiť existujúci: pripojiť bez formátovania",
"Use images only if the directory is on suitable storage.": "Typ images používajte iba vtedy, ak je priečinok na vhodnom úložisku.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Pre /config a MariaDB použite spravované úložisko. Pre /data môžete vybrať adresár hostiteľa/NAS, ktorý vyžaduje samostatné zálohovanie.",
"Use mp1/mp2/... for extra mount points.": "Pre ďalšie mount pointy použite mp1/mp2/...",
"Use only when unprivileged containers cannot meet your needs": "Použite iba vtedy, keď neprivilegované kontajnery nestačia vašim potrebám",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Použite možnosť 1 na pridanie lokálneho disku (ako úložisko Proxmoxu a/alebo ako mount na hostovi).",
@@ -8144,6 +8184,7 @@
"View Disk Storages": "Zobraziť diskové úložiská",
"View Mount Points": "Zobraziť mount pointy",
"View NFS Mounts (pvesm + fstab)": "Zobraziť NFS mounty (pvesm + fstab)",
"View USB/IP status": "Zobraziť stav USB/IP",
"View backup metadata": "Zobraziť metadáta zálohy",
"View current paths": "Zobraziť aktuálne cesty",
"View existing folders in /mnt": "Zobraziť existujúce priečinky v /mnt",
+41
View File
@@ -246,6 +246,7 @@
"Aborted": "Avbruten",
"About to restore": "På väg att återställa",
"Absolute directory path to use as backup target:": "Absolut katalogsökväg att använda som mål för säkerhetskopiering:",
"Absolute path of the client SSH private key:": "Absolut sökväg till klientens privata SSH-nyckel:",
"Absolute path to a file or directory you want backed up:": "Absolut sökväg till en fil eller katalog som du vill säkerhetskopiera:",
"Acceleration": "Acceleration",
"Acceleration configuration cancelled": "Acceleration konfiguration inställd",
@@ -619,6 +620,8 @@
"Authentication:": "Autentisering:",
"Authorization failed": "Auktorisering misslyckades",
"Authorization successful": "Auktoriseringen lyckades",
"Authorize a client SSH public key": "Auktorisera en klients offentliga SSH-nyckel",
"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 this key on the server": "Auktorisera denna nyckel på servern",
"Authorize via 'pct exec' (Borg server is an LXC on a PVE host I can SSH into)": "Auktorisera via 'pct exec' (Borg-servern är en LXC på en PVE-värd jag kan SSH till)",
"Authorized": "Auktoriserad",
@@ -1299,6 +1302,7 @@
"Conflicting utilities removed": "Motstridiga verktyg har tagits bort",
"Connect Zigbee devices to MQTT with a browser-based setup interface.": "Anslut Zigbee-enheter till MQTT med ett webbaserat konfigurationsgränssnitt.",
"Connect a Coral Accelerator and try again.": "Anslut en Coral Accelerator och försök igen.",
"Connect a remote USB device": "Anslut en fjärransluten USB-enhet",
"Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.": "Anslut den, eller ta bort den från behållaren i Proxmox (Resurser), och kör återställningen igen.",
"Connect your devices and users together in your own secure virtual private network.": "Anslut dina enheter och användare tillsammans i ditt eget säkra virtuella privata nätverk.",
"Connect your devices into a secure WireGuard®-based overlay network with SSO, MFA and granular access controls.": "Anslut dina enheter till ett säkert WireGuard®-baserat överläggsnätverk med SSO, MFA och granulära åtkomstkontroller.",
@@ -1961,12 +1965,14 @@
"Directus administrator": "Directus-administratör",
"Disable NIC Offloading (Intel e1000e)": "Inaktivera NIC-avlastning (Intel e1000e)",
"Disable Secure Boot in the UEFI firmware and run the installer again. Nothing has been changed.": "Inaktivera Secure Boot i UEFI-firmware och kör installationsprogrammet igen. Inget har ändrats.",
"Disable USB/IP server": "Inaktivera USB/IP-servern",
"Disable autostart on conflicting VM": "Inaktivera autostart på VM i konflikt",
"Disable autostart on one VM to avoid startup conflicts.": "Inaktivera autostart på en virtuell dator för att undvika startkonflikter.",
"Disable job": "Inaktivera jobbet",
"Disable kernel audit messages (optional but recommended):": "Inaktivera kärngranskningsmeddelanden (valfritt men rekommenderas):",
"Disable onboot on affected VM(s)/CT(s)": "Inaktivera onboot på berörda virtuella dator(er)/CT(ar)",
"Disable portmapper/rpcbind": "Inaktivera portmapper/rpcbind",
"Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.": "Inaktivera servern och frigör alla USB-enheter som hanteras av ProxMenux? Sparade val behålls till nästa aktivering.",
"Disabled": "Inaktiverad",
"Disabling High Availability services...": "Inaktiverar tjänster med hög tillgänglighet...",
"Disabling ZFS autotrim applied by ProxMenux...": "Inaktiverar ZFS autotrim som tillämpas av ProxMenux...",
@@ -2199,6 +2205,7 @@
"Enable Remote Desktop (RDP) before disabling the virtual display.": "Aktivera Remote Desktop (RDP) innan du inaktiverar den virtuella skärmen.",
"Enable SSD emulation for this disk?": "Vill du aktivera SSD-emulering för den här disken?",
"Enable TCP BBR/Fast Open control": "Aktivera TCP BBR/Fast Open-kontroll",
"Enable USB/IP server": "Aktivera USB/IP-servern",
"Enable VA-API transcoding and HDR10/Dolby Vision to SDR tone mapping": "Aktivera VA-API-transkodning och HDR10/Dolby Vision till SDR-tonkartläggning",
"Enable VA-API, hardware encoding and OpenCL tone mapping in Jellyfin": "Aktivera VA-API, hårdvarukodning och OpenCL tonkartläggning i Jellyfin",
"Enable VFIO IOMMU support": "Aktivera VFIO IOMMU-stöd",
@@ -3212,6 +3219,7 @@
"If pvesm path returned a DEVICE (LVM):": "Om pvesm path returnerade en ENHET (LVM):",
"If pvesm path returned a DIRECTORY (ZFS/BTRFS):": "Om pvesm path returnerade en KATALOG (ZFS/BTRFS):",
"If repositories error:": "Om förrådsfel:",
"If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.": "Om USB/IP-tjänsten stoppas oväntat, sluta dela enheten medan den fortfarande är ansluten för att frigöra den. I sällsynta fall frigörs den bara genom en omstart av värden.",
"If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.": "Om behållaren redan hade ändrats, återställs dess tidigare inbyggda säkerhetskopia. Delade värdkataloger återställs inte. Diskarna för det misslyckade försöket tas bort när rensningen lyckas.",
"If the host freezes, remove hostpci entries from": "Om värden fryser, ta bort hostpci-poster från",
"If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If anything fails, the backup is restored.": "Om bildkanalen har en ny version stoppas CT, säkerhetskopieras och verifieras och ersätts med den nya bilden för att uppdatera behållaren. Om något misslyckas återställs säkerhetskopian.",
@@ -3406,6 +3414,7 @@
"Install server packages inside CT:": "Installera serverpaket inuti CT:",
"Install terminal multiplexers": "Installera terminalmultiplexer",
"Install the Edge TPU runtime (libedgetpu1-std)": "Installera Edge TPU runtime (libedgetpu1-std)",
"Install the USB/IP dependencies from the configured Debian repositories?": "Installera USB/IP-beroendena från de konfigurerade Debian-paketförråden?",
"Install this image?": "Installera den här bilden?",
"Install with Cloud-Init script": "Installera med Cloud-Init-skript",
"Install with ISO from UUP Dump": "Installera med ISO från UUP Dump",
@@ -3596,6 +3605,7 @@
"Invalid check package:": "Ogiltigt checkpaket:",
"Invalid configuration path:": "Ogiltig konfigurationsväg:",
"Invalid consume/export volumes": "Ogiltiga konsum/exportvolymer",
"Invalid container device GID": "Ogiltigt enhets-GID i containern",
"Invalid container path:": "Invalid containerväg:",
"Invalid credential file path:": "Invalid credential filväg:",
"Invalid declarative entrypoint": "Invalid deklarativ ingång",
@@ -3890,9 +3900,11 @@
"Line to paste (single line, including \"command=...\" prefix):": "Rad att klistra in (en rad, inklusive \"command=...\" prefix):",
"LinkStack": "LinkStack",
"Linux Installation Options": "Linux installationsalternativ",
"Linux guest setup and migration notes": "Konfiguration av Linux-gäster och information om migrering",
"Linux/Mac path:": "Linux/Mac sökväg:",
"LinuxServer Jellyfin with optional GPU passthrough": "LinuxServer Jellyfin med valfri GPU genomgång",
"LinuxServer MariaDB requires a user, database and password": "LinuxServer MariaDB requires en användare, databas och lösenord",
"LinuxServer Nextcloud with MariaDB and Redis": "LinuxServer Nextcloud med MariaDB och Redis",
"List Available Disks": "Lista tillgängliga diskar",
"List IOMMU group mapping": "Lista IOMMU-gruppkartläggning",
"List NVMe devices": "Lista NVMe-enheter",
@@ -4397,6 +4409,7 @@
"Name or part of the option or of the application": "Namn eller del av alternativet eller applikationen",
"Name:": "Namn:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>": "Namngivna konton behöver privat läge. Stoppa behållaren, ställ in offentligt: falskt i /config/config.js, starta den igen och skapa varje användare med: pct exec <CTID> - env THELOUNGE_HOME=/config s6-setuidgid abc thelounge addera <user>",
"Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.": "En inbyggd version av Borg är inte tillgänglig. Installera paketet borgbackup för värdens arkitektur för att använda Borg-säkerhetskopior.",
"Needed:": "Behövs:",
"Needs": "Kräver",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "Varken /etc/kernel/cmdline eller /etc/default/grub hittades.",
@@ -4472,6 +4485,7 @@
"Next Step Required": "Nästa steg krävs",
"Next Steps:": "Nästa steg:",
"Next step: stop that VM first, then run": "Nästa steg: stoppa den virtuella datorn först och kör sedan",
"Nextcloud Web UI": "Nextclouds webbgränssnitt",
"Nextcloud configuration cancelled": "Nextcloud konfiguration annullerad",
"Nextcloud gives you access to all your files wherever you are.": "Nextcloud ger dig tillgång till alla dina filer var du än är.",
"Nextcloud volume size in GB": "Nextcloud volymstorlek i GB",
@@ -4646,6 +4660,7 @@
"No duplicate IP addresses": "Inga dubbletter av IP-adresser",
"No duplicate repositories found": "Inga dubbletter av arkiv hittades",
"No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.": "Inga lämpliga styrenhets-/NVMe-enheter återstår efter SR-IOV-filtreringen. Hoppar över.",
"No eligible USB devices found. Hubs, mass storage and network adapters are excluded.": "Inga lämpliga USB-enheter hittades. Hubbar, masslagringsenheter och nätverksadaptrar är undantagna.",
"No eligible controllers remain after SR-IOV filtering.": "Inga kvalificerade kontroller finns kvar efter SR-IOV-filtrering.",
"No encryption key is stored on this host. Choose how to set one up:": "Ingen krypteringsnyckel lagras på denna värd. Välj hur du ställer in en:",
"No exportable VM disks were found (CD-ROM/cloud-init are excluded).": "Inga exporterbara VM-diskar hittades (CD-ROM/cloud-init är undantagna).",
@@ -4730,6 +4745,7 @@
"No self-test log available for": "Ingen självtestlogg tillgänglig för",
"No server IP or hostname provided.": "Ingen server-IP eller värdnamn har angetts.",
"No share access test succeeded with guest authentication.": "Inget test av delad åtkomst lyckades med gästautentisering.",
"No shared devices are available on this server.": "Inga delade enheter är tillgängliga på denna server.",
"No shared media content.": "Inget delat medieinnehåll.",
"No shared mount detected. Applying standard local access.": "Inget delat fäste upptäcktes. Använder standard lokal åtkomst.",
"No shares configured.": "Inga delningar har konfigurerats.",
@@ -5128,6 +5144,7 @@
"Path of the Compose file": "Path of the Compose filen",
"Path of the oci-compose file": "Sökväg till oci-compose-filen",
"Path removed:": "Sökväg borttagen:",
"Path to the client SSH public key on this server:": "Sökväg till klientens offentliga SSH-nyckel på den här servern:",
"Path:": "Väg:",
"Paths applied:": "Tillämpade sökvägar:",
"Paths included in backup": "Sökvägar som ingår i säkerhetskopia",
@@ -5370,6 +5387,7 @@
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux installerar dem inte. Installationen frågar hur man ansluter till dem.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux validerar inte innehållet; alla nyckelfiler som din PBS accepterar accepteras här.",
"ProxMenux files:": "ProxMenux filer:",
"ProxMenux generates the administrator password and shows it at the end of the installation. Change it after the first login.": "ProxMenux genererar administratörslösenordet och visar det i slutet av installationen. Ändra det efter första inloggningen.",
"ProxMenux logo applied": "ProxMenux-logotyp applicerad",
"ProxMenux only acts as a launcher — once the script starts, control leaves ProxMenux.": "ProxMenux fungerar bara som en start - när skriptet startar lämnar kontrollen ProxMenux.",
"ProxMenux saved it locally at:": "ProxMenux sparade det lokalt på:",
@@ -5590,6 +5608,7 @@
"Recreation prepared": "Rekreation förberedd",
"Recursive DNS resolver.": "Rekursiv DNS-resolver.",
"Recursive and authoritative DNS server.": "Rekursiv och auktoritativ DNS-server.",
"Redis locking is configured automatically. Existing Nextcloud configuration files are preserved.": "Fillåsning med Redis konfigureras automatiskt. Befintliga konfigurationsfiler för Nextcloud bevaras.",
"Redmine": "Redmine",
"Refresh APT index and verify repositories:": "Uppdatera APT-index och verifiera förråd:",
"Refresh catalog": "Uppdatera katalogen",
@@ -5673,6 +5692,7 @@
"Remove Proxmox NFS storage:": "Ta bort Proxmox NFS-lagring:",
"Remove Proxmox iSCSI storage:": "Ta bort Proxmox iSCSI-lagring:",
"Remove Secure Gateway? State will be preserved.": "Ta bort Secure Gateway? Staten kommer att bevaras.",
"Remove a remote USB connection": "Ta bort en fjärransluten USB-anslutning",
"Remove custom paths": "Ta bort anpassade sökvägar",
"Remove disk references from affected VM(s)/CT(s) config": "Ta bort diskreferenser från berörda virtuella datorer/CT-konfigurationer",
"Remove every registered gasket DKMS version": "Ta bort alla registrerade gasket DKMS-versioner",
@@ -5693,6 +5713,7 @@
"Remove the lock left on a container by an interrupted task": "Ta bort låset som en avbruten uppgift lämnat på en container",
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Ta bort de sparade referenserna för {registry}?\nAppar som körs fortsätter att fungera.",
"Remove the unprivileged flag from configuration:": "Ta bort den oprivilegierade flaggan från konfigurationen:",
"Remove this connection and detach its USB device?": "Ta bort denna anslutning och koppla från dess USB-enhet?",
"Remove unused packages and their config": "Ta bort oanvända paket och deras konfiguration",
"Remove: delete the application and its containers": "Ta bort: ta bort programmet och dess behållare",
"Removed": "Borttagen",
@@ -5915,9 +5936,11 @@
"Run mode: Unattended": "Körläge: Oövervakad",
"Run now": "Kör nu",
"Run security audit now": "Kör säkerhetsgranskning nu",
"Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.": "Kör klienten i den Linux-VM som behöver USB-enheten. Installera usbip, openssh-client och kmod och kopiera sedan usbip_manager.py till samma sökväg som på servern. Konfigurera nyckelautentisering och verifiera serverns värdnyckel innan du lägger till en anslutning. Klienten använder gästens kärna och kan återansluta efter migrering medan den fysiska USB-enheten stannar på servern. Oprivilegierade LXC och Home Assistant OS behöver en egen konfiguration av kärna, enheter eller tillägg. En fysisk enhet kan bara vara ansluten till en klient åt gången.",
"Run the following inside the VM:": "Kör följande inuti den virtuella datorn:",
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Kör release-kanalväxeln från en SSH-session eller Proxmox-värdkonsolen med:",
"Run the update from an SSH session or the Proxmox host console with:": "Kör uppdateringen från en SSH-session eller Proxmox-värdkonsolen med:",
"Run this menu as root.": "Kör denna meny som root.",
"Run this operation as root.": "Kör denna åtgärd som root.",
"Run upgrade checklist script:": "Kör skript för uppgraderingschecklista:",
"Running": "Spring",
@@ -6142,6 +6165,7 @@
"Select a new language for the menu:": "Välj ett nytt språk för menyn:",
"Select a post-installation script:": "Välj ett efterinstallationsskript:",
"Select a pre-configured Linux VM script to execute:": "Välj ett förkonfigurerat Linux VM-skript att köra:",
"Select a saved USB/IP device:": "Välj en sparad USB/IP-enhet:",
"Select a script or action:": "Välj ett skript eller en åtgärd:",
"Select a share to delete:": "Välj en delning att radera:",
"Select a specific Coral or USB node, not the whole /dev": "Välj en specifik Coral eller USB-nod, inte hela/dev",
@@ -6219,6 +6243,7 @@
"Select the Linux distribution to install:": "Välj Linux-distributionen att installera:",
"Select the NAS system to install:": "Välj det NAS-system som ska installeras:",
"Select the NVIDIA driver version to install:": "Välj NVIDIA-drivrutinsversionen att installera:",
"Select the USB device to share:": "Välj USB-enheten som ska delas:",
"Select the VM to add the GPU to:": "Välj den virtuella datorn för att lägga till GPU:n:",
"Select the VM to which you want to add disks:": "Välj den virtuella datorn som du vill lägga till diskar till:",
"Select the VM where you want to import the disk image:": "Välj den virtuella datorn där du vill importera skivavbildningen:",
@@ -6243,6 +6268,7 @@
"Select the parent job to attach to:": "Välj det överordnade jobbet som ska bifogas:",
"Select the paths to restore (one per backup entry):": "Välj sökvägarna som ska återställas (en per säkerhetskopia):",
"Select the privileged LXC container to convert:": "Välj den privilegierade LXC-behållaren att konvertera:",
"Select the remote USB device:": "Välj den fjärranslutna USB-enheten:",
"Select the storage that will receive the backups:": "Välj lagringsutrymmet som ska ta emot säkerhetskopiorna:",
"Select the storage volume for disk import:": "Välj lagringsvolym för diskimport:",
"Select the sync interval (in hours):": "Välj synkroniseringsintervall (i timmar):",
@@ -6329,6 +6355,7 @@
"Server IDs to test against, separated by commas (optional)": "Server-ID:n att testa mot, separerade med kommatecken (valfritt)",
"Server IP/hostname is correct": "Serverns IP/värdnamn är korrekt",
"Server IP:": "Server IP:",
"Server SSH port:": "Serverns SSH-port:",
"Server is accessible:": "Servern är tillgänglig:",
"Server is online": "Servern är online",
"Server is running": "Servern körs",
@@ -6390,6 +6417,7 @@
"Share Access Error": "Dela åtkomstfel",
"Share Name": "Dela namn",
"Share Options": "Delningsalternativ",
"Share a local USB device": "Dela en lokal USB-enhet",
"Share added successfully.": "Delningen har lagts till.",
"Share deleted and Samba service restarted.": "Delningen raderades och Samba-tjänsten startade om.",
"Share exists:": "Delning finns:",
@@ -6397,6 +6425,7 @@
"Share name:": "Dela namn:",
"Share not found on server:": "Delningen hittades inte på servern:",
"Share path:": "Dela sökväg:",
"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 updated successfully.": "Delningen har uppdaterats.",
"Share:": "Dela:",
"ShareCoveX panel": "ShareCoveX panel",
@@ -6665,6 +6694,8 @@
"Still has issues": "Har fortfarande problem",
"Stop - shuts the guest down during backup": "Stopp - stänger av gästen under säkerhetskopiering",
"Stop it first and run this option again.": "Stoppa det först och kör det här alternativet igen.",
"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 that VM, then choose:": "Stoppa den VM och välj sedan:",
"Stop the CT, unmount the disk on the HOST, and remount with:": "Stoppa CT, avmontera disken på HOST och montera om med:",
"Stop the VM/CT before formatting this disk.": "Stoppa VM/CT innan du formaterar denna disk.",
@@ -7020,6 +7051,7 @@
"The container could not be removed automatically:": "Behållaren kunde inte tas bort automatiskt:",
"The container could not be started:": "Behållaren kunde inte startas:",
"The container creation does not match the prepared instance": "Behållarens skapelse matchar inte den förberedda instansen",
"The container device settings changed since the last recorded operation": "Inställningarna för enheten i containern har ändrats sedan den senaste registrerade åtgärden",
"The container devices do not match the saved record": "Container-enheterna matchar inte det sparade rekordet",
"The container did not stop to update its persistent configuration:": "Behållaren slutade inte uppdatera sin ihållande konfiguration:",
"The container did not stop; its disks are not touched": "Behållaren slutade inte; dess diskar rörs inte",
@@ -7301,6 +7333,7 @@
"The record no longer belongs to this operation": "Skivan tillhör inte längre denna operation",
"The record of a member was replaced; the assembly is not resumed": "En medlems rekord ersattes; församlingen återupptas inte.",
"The record or diagnosis could not be completed; no update was run.": "Rekordet eller diagnosen kunde inte slutföras; ingen uppdatering kördes.",
"The recorded container device GID does not match its configuration": "Det registrerade enhets-GID:t i containern stämmer inte med konfigurationen",
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "Återhämtningen slutfördes inte. Granska loggen och välj \"Recover\" igen för den här behållaren i OCI-hanteringsmenyn.",
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "Återställningen slutfördes inte. Granska loggen och välj \"{choice}\" igen för den här containern i OCI-hanteringsmenyn.",
"The registry authentication file is invalid; it was not changed.": "Registerautentiseringsfilen är ogiltig; det ändrades inte.",
@@ -7368,6 +7401,7 @@
"The selected Intel GPU has non-FLR reset support and unknown subtype": "Den valda Intel GPU:n har stöd för icke-FLR-återställning och okänd undertyp",
"The selected Intel GPU is currently in power state D3cold": "Den valda Intel GPU:n är för närvarande i strömtillstånd D3cold",
"The selected Intel render device does not exist:": "Den valda Intel render-enheten finns inte:",
"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 VM": "Den valda virtuella datorn",
"The selected VM is running.": "Den valda virtuella datorn körs.",
"The selected base folder does not exist and could not be created:": "Den valda basmappen finns inte och kunde inte skapas:",
@@ -7707,6 +7741,9 @@
"USB drives mounted now:": "USB-enheter monterade nu:",
"USB libedgetpu1": "USB libedgetpu1",
"USB or serial device node": "USB eller seriell enhetsnod",
"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:",
"UUP Dump script not found.": "UUP Dump-skript hittades inte.",
"UUp Dump ISO creator Custom": "UUp Dump ISO-skapare Anpassad",
"Ubooquity is a free, lightweight and easy-to-use home server for your comics and ebooks. Use it to access your files from anywhere, with a tablet, an e-reader, a phone or a computer.": "Ubooquity är en gratis, lätt och lättanvänd hemserver för dina serier och e-böcker. Använd den för att komma åt dina filer var som helst, med en surfplatta, en e-läsare, en telefon eller en dator.",
@@ -7863,6 +7900,7 @@
"Untrusted or modified NVIDIA hook": "Untrusted eller modifierad NVIDIA hook",
"Unused image removed from the cache:": "Oanvänd bild bort från cache:",
"Unused images removed from the cache:": "Oanvända bilder som tagits bort från cache:",
"Unused local loopback TCP port for this device:": "Ledig lokal TCP-port på loopback för denna enhet:",
"Update": "Uppdatering",
"Update Available": "Uppdatering tillgänglig",
"Update Ceph repository (Only if using Ceph):": "Uppdatera Ceph arkiv (endast om du använder Ceph):",
@@ -7961,6 +7999,7 @@
"Use a privileged LXC for NFS server/client workflows.": "Använd en privilegierad LXC för NFS-server/klient-arbetsflöden.",
"Use a privileged LXC for Samba client/server workflows.": "Använd en privilegierad LXC för Samba klient/server arbetsflöden.",
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Använd ett registervärdnamn, till exempel ghcr.io eller registry.example.com:5000.",
"Use a trusted HTTPS reverse proxy. Upgrade Nextcloud one major version at a time.": "Använd en omvänd HTTPS-proxy med ett betrott certifikat. Uppgradera Nextcloud till nästa huvudversion utan att hoppa över någon.",
"Use an existing SSH private key file on this host": "Använd en befintlig privat SSH-nyckelfil på denna värd",
"Use an existing keyfile": "Använd en befintlig nyckelfil",
"Use an image reference, for example ghcr.io/owner/app:latest.": "Använd en bildreferens, till exempel ghcr.io/owner/app:latest.",
@@ -7974,6 +8013,7 @@
"Use existing filesystem": "Använd befintligt filsystem",
"Use existing: mount without formatting": "Använd befintlig: montera utan formatering",
"Use images only if the directory is on suitable storage.": "Använd endast bilder om katalogen finns på lämplig lagring.",
"Use managed storage for /config and MariaDB. A host/NAS directory can be selected for /data and needs a separate backup.": "Använd hanterad lagring för /config och MariaDB. För /data kan du välja en katalog på värden/NAS som behöver säkerhetskopieras separat.",
"Use mp1/mp2/... for extra mount points.": "Använd mp1/mp2/... för extra monteringspunkter.",
"Use only when unprivileged containers cannot meet your needs": "Använd endast när oprivilegierade behållare inte kan uppfylla dina behov",
"Use option 1 to add a local disk (as Proxmox storage and/or as a host mount).": "Använd alternativ 1 för att lägga till en lokal disk (som Proxmox-lagring och/eller som värdmontering).",
@@ -8144,6 +8184,7 @@
"View Disk Storages": "Visa disklagringar",
"View Mount Points": "Se Mount Points",
"View NFS Mounts (pvesm + fstab)": "Visa NFS-fästen (pvesm + fstab)",
"View USB/IP status": "Visa USB/IP-status",
"View backup metadata": "Visa metadata för säkerhetskopiering",
"View current paths": "Visa aktuella vägar",
"View existing folders in /mnt": "Visa befintliga mappar i /mnt",
+2
View File
@@ -111,6 +111,8 @@ Selecting an application, or giving a definition, opens the same sequence.
4. **Resolve.** The registry is queried, the architecture of the host is selected and the digest the tag points to at that moment is fixed.
5. **Download and verify.** Skopeo downloads the image as an OCI archive. Every layer is checked against its digest before anything is created.
6. **Build.** `pct create` builds the rootfs from the archive and imports `Entrypoint`, `Cmd`, `Env`, `User`, `WorkingDir` and `StopSignal` from the image.
Proxmox reads the image inside the container's user namespace. Readable template-storage archives use the existing path without a copy. If a verified archive or its parents are private, the native create wrapper makes a temporary root-owned copy under `/var/tmp`, readable only by the mapped root group, and removes it after creation, failure or a catchable interruption. The cached image, storage directories, registry credentials and caller permissions stay unchanged. Reflinks are used where available; otherwise this uncommon path needs temporary space for one image copy. Only the `pct create` child receives the normal Proxmox creation umask.
7. **Connect.** The volumes are added as mount points, then the devices, the environment, the network and the security settings the oci-compose declares.
8. **Prepare.** Each new volume is made ready for the first start: `lost+found` is removed and the directory is given to the user the application writes with. The files the oci-compose asks to write are written.
9. **Start and check.** The container starts, an address is waited for, the settings that need the running application are applied, and the service is checked. A failed first start is reported as a failed installation.
+1 -1
View File
@@ -2,7 +2,7 @@
<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->
52 of 554 applications in the OCI catalog have been tested for real: 49 in the ProxMenux lab and 3 by the community. The OCI installer shows them as verified, with a ✓ in the lists.
52 of 555 applications in the OCI catalog have been tested for real: 49 in the ProxMenux lab and 3 by the community. The OCI installer shows them as verified, with a ✓ in the lists.
Each test describes the scenario that was run and names the image it ran on. It does not cover every possible configuration of the application, and a newer image has not been tested until someone reports it.
+1 -1
View File
@@ -17,7 +17,7 @@
"category_label": "AI / Coding & Dev-Tools",
"author": "Nous Research",
"developer": "Nous Research",
"icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/hermes.webp",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hermes-agent.webp",
"thumbnail": null,
"screenshots": [],
"architectures": [
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -137,7 +137,7 @@
"NET_ADMIN",
"NET_RAW"
],
"entrypoint": "/bin/sh -c \"tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 10; tailscale web --listen 0.0.0.0:5252\""
"entrypoint": "/bin/sh -c 'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n \"s/.*inet \\([0-9.]*\\)\\/.*/\\1/p\" | head -n 1); [ -n \"$lan\" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen \"${lan:-0.0.0.0}:5252\"'"
}
}
],
@@ -243,7 +243,7 @@
},
"installer_profile": {
"runtime": {
"compose_entrypoint": "/bin/sh -c \"tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 10; tailscale web --listen 0.0.0.0:5252\""
"compose_entrypoint": "/bin/sh -c 'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n \"s/.*inet \\([0-9.]*\\)\\/.*/\\1/p\" | head -n 1); [ -n \"$lan\" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen \"${lan:-0.0.0.0}:5252\"'"
},
"network": {
"compose_mode": "host"
File diff suppressed because one or more lines are too long
+33 -1
View File
@@ -6150,7 +6150,7 @@
"repository": "https://hub.docker.com/r/nousresearch/hermes-agent",
"description": "Hermes Agent is a self-improving AI agent from Nous Research, designed to be a long-running collaborator rather than a one-off chatbot.\nIts built-in learning loop combines persistent memory, cross-session recall, and reusable skills so it can keep context and improve recurring workflows over time.\nWith a unified gateway, rich toolsets, and a browser-based dashboard, Hermes fits research, automation, cross-platform communication, and ongoing operational work.\n\n**Main Features:**\n- Built-in learning loop with persistent memory, session search, and reusable skills\n- One gateway for CLI, Telegram, Discord, Slack, WhatsApp, Signal, and more\n- Tool-driven workflows with terminal access, web search, browser automation, file editing, and MCP integration\n- Automation and coordination with cron jobs, subagents, the web dashboard, and an OpenAI-compatible API\n\n**Learn More:**\n- [Hermes Agent Official Website](https://hermes-agent.nousresearch.com/)\n- [Hermes Agent GitHub](https://github.com/NousResearch/hermes-agent)\n- [Hermes Agent Documentation](https://hermes-agent.nousresearch.com/docs/)\n",
"website": "https://hermes-agent.nousresearch.com/",
"icon": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/webp/hermes.webp",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/hermes-agent.webp",
"architectures": [
"amd64",
"arm64"
@@ -19414,6 +19414,38 @@
"optional_relaxed_confinement": false,
"requires_security_confirmation": false,
"hidden": false
},
{
"id": "nextcloud-mariadb",
"provider": "linuxserver",
"template_family": "curated-profile",
"title": "Nextcloud MariaDB",
"repository": "https://github.com/linuxserver/docker-nextcloud",
"description": "LinuxServer Nextcloud, MariaDB 10.11 and Redis 7 in three native OCI LXC containers. /config and /data are separate; user files can use a host/NAS directory while database state stays in local managed storage.",
"website": "https://nextcloud.com/",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/nextcloud.webp",
"architectures": [
"amd64",
"arm64"
],
"updated_at": null,
"main_image": "lscr.io/linuxserver/nextcloud:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"replaces_discovered_ids": [],
"curated_path": "catalog/curated/nextcloud-mariadb.json",
"template_id": "image-nextcloud-mariadb",
"template": "apps/nextcloud-mariadb.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
"untranslated_blockers": [],
"requires_privileged_lxc": false,
"optional_privileged_lxc": false,
"requires_host_pid_namespace": false,
"requires_relaxed_confinement": false,
"optional_relaxed_confinement": false,
"requires_security_confirmation": false,
"hidden": false
}
],
"discovery": {
+4 -1
View File
@@ -8,7 +8,10 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
]
],
"runtime": {
"compose_entrypoint": "/bin/sh -c 'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n \"s/.*inet \\([0-9.]*\\)\\/.*/\\1/p\" | head -n 1); [ -n \"$lan\" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen \"${lan:-0.0.0.0}:5252\"'"
}
}
}
}
+3
View File
@@ -161,6 +161,9 @@
],
"wordcraft": [
"/storage"
],
"nextcloud-mariadb": [
"/data"
]
},
"sizes": {
@@ -0,0 +1,42 @@
<?php
// Prepare a fresh LinuxServer Nextcloud without replacing persistent user settings.
$dir = '/config/www/nextcloud/config';
umask(0077);
if (!is_dir($dir) && !mkdir($dir, 0770, true)) { throw new RuntimeException('Cannot create the Nextcloud config directory'); }
// Once the directory belongs to the application user, only that user could
// have placed a link in it: write as that user, so a link reaches nothing the
// application could not already change. PHP follows links when it opens a
// path, so a link in place of a file is also refused.
$owner = fileowner($dir);
if (posix_geteuid() === 0 && $owner !== 0) {
if (!posix_setgid(filegroup($dir)) || !posix_setuid($owner)) { throw new RuntimeException('Cannot switch to the Nextcloud user'); }
}
function create_private($path, $content) {
if (is_link($path)) { throw new RuntimeException('Refusing a link in place of ' . basename($path)); }
$handle = @fopen($path, 'x');
if ($handle === false || fwrite($handle, $content) !== strlen($content) || !fclose($handle)) { throw new RuntimeException('Cannot write ' . basename($path)); }
}
if (!is_file($dir . '/config.php') && !is_file($dir . '/autoconfig.php')) {
foreach (['MYSQL_HOST', 'MYSQL_DATABASE', 'MYSQL_USER', 'MYSQL_PASSWORD', 'NEXTCLOUD_ADMIN_PASSWORD'] as $name) {
if (getenv($name) === false || getenv($name) === '') { throw new RuntimeException('Missing Nextcloud setting: ' . $name); }
}
// Keep the defaults that LinuxServer copies only into an empty config directory.
$defaults = '/app/www/src/config';
if (is_dir($defaults)) {
foreach (new DirectoryIterator($defaults) as $file) {
if ($file->isDot() || !$file->isFile()) { continue; }
$target = $dir . '/' . $file->getFilename();
if (!file_exists($target) && !is_link($target)) { create_private($target, file_get_contents($file->getPathname())); }
}
}
// With the administrator included, Nextcloud installs itself on the first
// request, so nobody else on the network can claim the setup page.
$settings = ['dbtype' => 'mysql', 'dbhost' => getenv('MYSQL_HOST'), 'dbname' => getenv('MYSQL_DATABASE'), 'dbuser' => getenv('MYSQL_USER'), 'dbpass' => getenv('MYSQL_PASSWORD'), 'directory' => '/data', 'adminlogin' => 'admin', 'adminpass' => getenv('NEXTCLOUD_ADMIN_PASSWORD')];
create_private($dir . '/autoconfig.php', "<?php\n\$AUTOCONFIG = " . var_export($settings, true) . ";\n");
}
$path = $dir . '/proxmenux-redis.config.php';
// Installed instances keep their existing cache configuration unchanged.
if (!is_file($path) && !is_link($path) && !is_file($dir . '/config.php')) {
$settings = ['filelocking.enabled' => true, 'memcache.local' => '\OC\Memcache\APCu', 'memcache.locking' => '\OC\Memcache\Redis', 'redis' => ['host' => 'redis', 'port' => 6379, 'timeout' => 0.0]];
create_private($path, "<?php\n\$CONFIG = " . var_export($settings, true) . ";\n");
}
+28 -8
View File
@@ -14,11 +14,13 @@ import os
from pathlib import Path
import re
import socket
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.request
import urllib.error
import urllib.parse
import uuid
import xml.etree.ElementTree as ET
@@ -245,12 +247,19 @@ def relay_child_output(stream):
return result
def http_url(scheme, host, port, path):
"""Without the default port of its scheme: an application that records its
first request, like Nextcloud's trusted domain, sees the host a browser sends."""
default = (scheme, int(port)) in (('https', 443), ('http', 80))
return f"{scheme}://{host}{'' if default else f':{port}'}{path}"
def assign_health_address(service):
"""Bind a generated dependency probe to its allocated private address."""
check = service['healthcheck']
if check['type'] == 'http':
endpoint = check['endpoint']
check['url'] = f"{endpoint['scheme']}://{service['ip']}:{endpoint['port']}{endpoint['path']}"
check['url'] = http_url(endpoint['scheme'], service['ip'], endpoint['port'], endpoint['path'])
elif check['type'] == 'tcp':
check['host'] = service['ip']
@@ -381,7 +390,7 @@ def lan_access_urls(services, subnet, strict=True):
if not candidates:
raise RuntimeError(f"{translate('No LAN address was obtained for the service:')} {service['name']}")
urls.append({'label':service['name'],
'url':f"{endpoint['scheme']}://{candidates[0]}:{endpoint['port']}{endpoint['path']}"})
'url':http_url(endpoint['scheme'], candidates[0], endpoint['port'], endpoint['path'])})
except Exception:
if strict:
raise
@@ -662,24 +671,35 @@ def attach_mounts(service, temporary):
oci_nested_mounts.follow(vmid)
def web_answers(vmid, url, opener):
def web_answers(vmid, url, opener, verify_tls=False):
from oci_installation_state import container_curl
curl = container_curl(vmid, lambda *args: run(*args).encode())
if curl != ['curl']:
run(*curl, '-fsS', '--noproxy', '*', '-o', '/dev/null', '--max-time', '4', url)
tls_args = [] if verify_tls else ['--insecure']
run(*curl, '-fsS', '--noproxy', '*', '-o', '/dev/null', '--max-time', '4', *tls_args, url)
return True
with opener.open(url,timeout=4) as response:
return response.status<400
try:
with opener.open(url,timeout=4) as response:
return response.status<400
except urllib.error.HTTPError as error:
error.close()
raise
def wait_web(primary, url):
deadline = time.monotonic()+primary['healthcheck']['timeout_seconds']
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
# Match the single-container installer and recovery probes: image-generated
# certificates are valid for local readiness, with strict TLS available.
verify_tls = primary['healthcheck'].get('verify_tls', False)
handlers = [urllib.request.ProxyHandler({})]
if not verify_tls:
handlers.append(urllib.request.HTTPSHandler(context=ssl._create_unverified_context()))
opener = urllib.request.build_opener(*handlers)
while True:
if run('pct','status',primary['vmid']).strip()!='status: running':
raise RuntimeError(f"{translate('The container stopped:')} {primary['name']} (CT {primary['vmid']})")
try:
if web_answers(primary['vmid'], url, opener):
if web_answers(primary['vmid'], url, opener, verify_tls=verify_tls):
check = primary['healthcheck']
if check.get('type') == 'exec':
remaining = max(1, deadline-time.monotonic())
+26 -8
View File
@@ -373,6 +373,18 @@ healthcheck_probe_url() {
&& (( status == 1 || status == 8 || status == 52 || status == 56 ))
}
# The address of the check, without the default port of its scheme: an
# application that records its first request, like Nextcloud's trusted domain,
# then sees the same host a browser sends.
healthcheck_url() {
local port=$1
if [[ ( $HC_SCHEME == https && $port == 443 ) || ( $HC_SCHEME == http && $port == 80 ) ]]; then
printf '%s://%s%s' "$HC_SCHEME" "$IP" "$HC_PATH"
else
printf '%s://%s:%s%s' "$HC_SCHEME" "$IP" "$port" "$HC_PATH"
fi
}
# Any of the addresses answering means the application is up; HC_URL keeps the
# one that answered.
healthcheck_probe() {
@@ -939,7 +951,7 @@ set_lxc_directive() {
add_device() {
local host_path=$1 expected_type=$2 mode_spec=$3 gid_strategy=$4 deny_write=$5
local device_uid=${6:-}
local device_uid=${6:-} container_gid=${7:-}
local mode gid dev_value
[[ $host_path == /dev/* && $host_path != *[[:space:]]* && $host_path != *","* ]] \
|| die "$(translate "Invalid device path:") $host_path"
@@ -963,7 +975,12 @@ add_device() {
|| die "$(translate "Invalid device UID:") $device_uid"
dev_value="${dev_value},uid=${device_uid}"
fi
if [[ $gid_strategy == host-device-gid ]]; then
if [[ -n $container_gid ]]; then
[[ $container_gid =~ ^[0-9]{1,10}$ ]] && (( container_gid < 4294967295 )) \
|| die "$(translate "Invalid container device GID"): $container_gid"
gid=$container_gid
dev_value="${dev_value},gid=${gid}"
elif [[ $gid_strategy == host-device-gid ]]; then
gid=$(stat -c '%g' "$host_path")
dev_value="${dev_value},gid=${gid}"
elif [[ $gid_strategy != none ]]; then
@@ -978,7 +995,7 @@ add_device() {
}
add_character_device() {
add_device "$1" character "$2" "$3" "$4" "${5:-}"
add_device "$1" character "$2" "$3" "$4" "${5:-}" "${6:-}"
}
prepare_nvidia_driver_links() {
@@ -2043,6 +2060,7 @@ while IFS= read -r DEVICE_ENCODED; do
DEVICE=$(printf '%s' "$DEVICE_ENCODED" | base64 -d)
DEVICE_KIND=$(jq -r '.kind // "character-device"' <<<"$DEVICE")
DEVICE_GID_ENV=$(jq -r '.append_host_device_gid_to_environment // empty' <<<"$DEVICE")
CONTAINER_GID=$(jq -r '.container_gid // empty' <<<"$DEVICE")
if [[ $DEVICE_KIND == nvidia-runtime ]]; then
NVIDIA_GID_ENV=$DEVICE_GID_ENV
if (( NVIDIA_RUNTIME_CONFIGURED == 0 )); then
@@ -2062,13 +2080,13 @@ while IFS= read -r DEVICE_ENCODED; do
case "$DEVICE_KIND" in
character-device)
DEVICE_UID=$(jq -r '.uid // empty' <<<"$DEVICE")
add_character_device "$HOST_PATH" "$MODE" "$GID_STRATEGY" "$DENY_WRITE" "$DEVICE_UID"
add_character_device "$HOST_PATH" "$MODE" "$GID_STRATEGY" "$DENY_WRITE" "$DEVICE_UID" "$CONTAINER_GID"
if [[ -n $DEVICE_GID_ENV ]]; then
append_deployment_environment_csv "$DEVICE_GID_ENV" "$(stat -c '%g' "$HOST_PATH")"
fi
;;
block-device)
add_device "$HOST_PATH" block "$MODE" "$GID_STRATEGY" "$DENY_WRITE"
add_device "$HOST_PATH" block "$MODE" "$GID_STRATEGY" "$DENY_WRITE" "" "$CONTAINER_GID"
if [[ -n $DEVICE_GID_ENV ]]; then
append_deployment_environment_csv "$DEVICE_GID_ENV" "$(stat -c '%g' "$HOST_PATH")"
fi
@@ -2078,7 +2096,7 @@ while IFS= read -r DEVICE_ENCODED; do
FOUND_TREE_DEVICE=0
while IFS= read -r TREE_DEVICE; do
[[ -n $TREE_DEVICE ]] || continue
add_character_device "$TREE_DEVICE" "$MODE" "$GID_STRATEGY" "$DENY_WRITE"
add_character_device "$TREE_DEVICE" "$MODE" "$GID_STRATEGY" "$DENY_WRITE" "" "$CONTAINER_GID"
FOUND_TREE_DEVICE=1
done < <(find "$HOST_PATH" -print 2>/dev/null | while IFS= read -r path; do
[[ -c $path ]] && printf '%s\n' "$path"
@@ -2345,12 +2363,12 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
(( HC_STABILITY < HC_TIMEOUT )) \
|| die "$(translate "The stability period must be shorter than the healthcheck timeout")"
[[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")"
HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}"
HC_URL=$(healthcheck_url "$HC_PORT")
HC_URLS=("$HC_URL")
while IFS= read -r HC_ALT_PORT; do
[[ $HC_ALT_PORT =~ ^[0-9]+$ && $HC_ALT_PORT -ge 1 && $HC_ALT_PORT -le 65535 ]] \
|| die "$(translate "Invalid healthcheck port")"
HC_URLS+=("${HC_SCHEME}://${IP}:${HC_ALT_PORT}${HC_PATH}")
HC_URLS+=("$(healthcheck_url "$HC_ALT_PORT")")
done < <(jq -r '.alternative_ports[]? | tostring' <<<"$STARTUP_HEALTHCHECK")
HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK")
HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK")
+9 -1
View File
@@ -40,6 +40,14 @@ def drm_plan(deployment):
return dict(deployment, devices=[d for d in deployment.get('devices', []) if d.get('kind') != 'nvidia-runtime'])
def container_gid(device, host_value):
return drm.container_gid(device, host_value)
def preserve_recorded_container_gids(record, candidate, config):
drm.preserve_recorded_container_gids(record, candidate, config)
def planned(deployment):
result = drm.planned(drm_plan(deployment))
if nvidia.enabled(deployment):
@@ -69,7 +77,7 @@ def check(config, deployment):
filtered = []
for line in config.splitlines(keepends=True):
if re.match(rb'dev[0-9]+: ', line):
fields = dict(part.split('=', 1) for part in line.decode().strip().split(': ', 1)[1].split(','))
fields = drm.device_fields(line.decode().strip().split(': ', 1)[1])
if fields.get('path') in value['devices']:
continue
filtered.append(line)
+76 -2
View File
@@ -88,21 +88,95 @@ def planned(deployment):
raise ValueError(translate('Invalid device mode'))
if device.get('gid_strategy') not in ('none', 'host-device-gid'):
raise ValueError(translate('Unsupported device GID strategy'))
if 'container_gid' in device:
container_gid(device, value)
result[path] = value
return result
def container_gid(device, host_value):
"""The GID assigned to the device node in the LXC's user namespace.
A recorded container GID is distinct from the host device's GID. Proxmox
maps the configured LXC GID through the container's id map when it creates
its passthrough node; the numeric host GID is not automatically translated.
"""
if 'container_gid' in device:
gid = device['container_gid']
if isinstance(gid, bool) or not isinstance(gid, int) or not 0 <= gid < 4294967295:
raise ValueError(translate('Invalid container device GID'))
return gid
return host_value['gid'] if device['gid_strategy'] == 'host-device-gid' else 0
def _permission_fields(fields):
"""Everything but the GID, which the user may change in Proxmox."""
return (fields.get('path'), int(fields.get('mode', '0660'), 8),
int(fields.get('uid', 0)), fields.get('deny-write', '0'))
def preserve_recorded_container_gids(record, candidate, config):
"""Keep the GID each device has inside the LXC, including one changed in
Proxmox since the last operation, like the other Proxmox changes.
This is not a host-to-container GID translation: the configured GID is the
group Proxmox gives the node inside the container. Other permission changes
are still refused, and the host device is verified by ``verify_baseline``.
"""
saved_config = record.get('observed', {}).get('config')
if not isinstance(saved_config, str) or not saved_config:
return
saved = actual_devices(saved_config.encode())
current = actual_devices(config)
if set(saved) != set(current):
return # check() below reports the device set mismatch.
if any(_permission_fields(saved[path]) != _permission_fields(current[path]) for path in saved):
raise ValueError(translate('The container device settings changed since the last recorded operation'))
original_devices = record.get('deployment', {}).get('devices', [])
old_snapshots = planned(record.get('deployment', {}))
for device in original_devices:
path = device.get('host_path')
if path not in current or path not in old_snapshots:
continue
observed_gid = int(current[path].get('gid', 0))
if observed_gid != container_gid(device, old_snapshots[path]):
device['container_gid'] = observed_gid
for desired in candidate.get('deployment', {}).get('devices', []):
if (desired.get('host_path') == path
and desired.get('gid_strategy') == device.get('gid_strategy')
and 'container_gid' not in desired):
desired['container_gid'] = observed_gid
def verify(expected):
for path, value in expected.items():
if snapshot(path) != value:
raise ValueError(translate('The GPU identity or permissions changed; the container is not modified'))
def device_fields(value):
"""The options of a devN line. Proxmox writes its path either as `path=`
or, when the device was added from its own interface, as the first value
without a name: `dev0: /dev/dri/renderD128,gid=993`."""
fields = {}
for index, part in enumerate(value.split(',')):
if '=' in part:
key, item = part.split('=', 1)
fields[key] = item
elif index == 0 and part:
fields['path'] = part
else:
raise ValueError(translate('Incomplete device configuration'))
return fields
def actual_devices(config):
result = {}
for key, value in parse_config(config).items():
if re.fullmatch(r'dev[0-9]+', key):
fields = dict(part.split('=', 1) for part in value.split(','))
fields = device_fields(value)
path = fields.get('path')
if path in result:
raise ValueError(translate('Duplicated GPU device in the container'))
@@ -121,7 +195,7 @@ def check(config, deployment):
value = expected[path]
requested_mode = device.get('mode', '0660')
mode = value['mode'] if requested_mode == 'preserve-host' else int(requested_mode, 8)
gid = value['gid'] if device['gid_strategy'] == 'host-device-gid' else 0
gid = container_gid(device, value)
if (set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}
or int(fields.get('mode', '0660'), 8) != mode
or int(fields.get('gid', 0)) != gid
+4 -3
View File
@@ -58,9 +58,10 @@ def _mount(key, value, vmid):
def _device(key, value):
parts = value.split(',')
if any('=' not in part for part in parts):
raise ValueError(f'{key}: {translate("Incomplete device configuration")}')
fields = dict(part.split('=', 1) for part in parts)
try:
fields = gpu_devices.device_fields(value)
except ValueError:
raise ValueError(f'{key}: {translate("Incomplete device configuration")}') from None
if len(fields) != len(parts) or set(fields) - {'path', 'mode', 'gid', 'uid', 'deny-write'}:
raise ValueError(f'{key}: {translate("Unsupported device options")}')
path = fields.get('path')
+19
View File
@@ -472,7 +472,25 @@ def effective_healthcheck(template):
return {'type': 'running', 'timeout_seconds': 90, 'stability_seconds': 20}
# The Tailscale recipe first served its web interface on 0.0.0.0:5252. Once the
# web client of Tailscale is turned on, tailscaled takes that port on its own
# address and the container stopped at every start; subnet routes also need
# forwarding inside the container. A saved recipe with that exact command gets
# the current one. A command the user set in Proxmox is kept by the record.
TAILSCALE_PREVIOUS_ENTRYPOINT = ('/bin/sh -c "tailscaled --state=/var/lib/tailscale/tailscaled.state & sleep 10; '
'tailscale web --listen 0.0.0.0:5252"')
TAILSCALE_ENTRYPOINT = ('/bin/sh -c \'sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1; tailscaled --state=/var/lib/tailscale/tailscaled.state & i=0; while [ $i -lt 60 ]; do lan=$(ip -4 addr show dev eth0 2>/dev/null | sed -n "s/.*inet \\([0-9.]*\\)\\/.*/\\1/p" | head -n 1); [ -n "$lan" ] && break; i=$((i+1)); sleep 1; done; sleep 10; exec tailscale web --listen "${lan:-0.0.0.0}:5252"\'')
def current_recipe(template):
runtime = template.get('proxmox', {}).get('installer_profile', {}).get('runtime', {})
if (template.get('id') == 'image-tailscale'
and runtime.get('compose_entrypoint') == TAILSCALE_PREVIOUS_ENTRYPOINT):
runtime['compose_entrypoint'] = TAILSCALE_ENTRYPOINT
def with_default_healthcheck(contract):
current_recipe(contract['template'])
check = effective_healthcheck(contract['template'])
if check and check.get('type') != 'haos':
contract['template'].setdefault('proxmox', {}).setdefault('installer_profile', {})['startup_healthcheck'] = check
@@ -745,6 +763,7 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
if desired['rootfs'] != deployment['rootfs']:
raise ValueError(translate('Changing the rootfs or its storage requires a separate migration'))
actual = mounts(config)
gpu_devices.preserve_recorded_container_gids(record, candidate, config)
gpu_devices.check(config, deployment)
if baseline:
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
+4 -1
View File
@@ -50,9 +50,12 @@ def before_update(vmid, storage):
"""A storage that cannot receive the file gets its own backup first."""
if dump_dir(storage) is not None:
return None
# Like transaction.run(), scope PVE's creation umask to this child only.
# PBS reads the temporary LXC config as the guest-mapped UID; inheriting
# a private caller umask (077) makes its traversal/config files unreadable.
subprocess.run(['vzdump', str(vmid), '--storage', storage, '--mode', 'snapshot',
'--compress', 'zstd', '--notes-template', 'ProxMenux OCI: before the image update'],
check=True)
check=True, umask=0o022)
return storage
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Give Proxmox's mapped OCI reader access without publishing a private cache.
Ordinary readable templates use the existing zero-copy path. An inaccessible
archive gets a short-lived copy (reflink where supported), owned by root and
readable only by the mapped root group. Never chmod/chown the source image,
its parents, registry credentials, or the caller's process umask.
"""
from contextlib import contextmanager
import errno
import ctypes
import fcntl
import os
from pathlib import Path
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
STAGING_ROOT = Path('/var/tmp') # Deliberately independent of a private TMPDIR.
FICLONE = 0x40049409
def reader_ids(options):
"""Fresh pct creates use PVE's standard idmap, not a restored custom map."""
unprivileged = False
for index, option in enumerate(options):
if option == '--unprivileged':
unprivileged = index + 1 == len(options) or options[index + 1] not in ('0', 'false')
elif option.startswith('--unprivileged='):
unprivileged = option.partition('=')[2] not in ('0', 'false')
return (100000, 100000) if unprivileged else (0, 0)
def _allowed(path, uid, gid, required):
info = path.lstat()
if stat.S_ISLNK(info.st_mode):
return False
shift = 6 if info.st_uid == uid else 3 if info.st_gid == gid else 0
return bool((info.st_mode >> shift) & required)
def readable(path, uid, gid):
"""Conservative DAC check: an ACL or symlink may make staging unnecessary,
but never justify publishing the cache or broadening a storage directory."""
path = Path(os.path.abspath(path))
if uid == 0:
return path.is_file()
return (_allowed(path, uid, gid, 4)
and all(_allowed(parent, uid, gid, 1) for parent in path.parents))
def _copy(source, target):
source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
try:
if not stat.S_ISREG(os.fstat(source_fd).st_mode):
raise OSError(errno.EINVAL, os.strerror(errno.EINVAL), str(source))
with os.fdopen(source_fd, 'rb', closefd=False) as incoming, target.open('xb') as outgoing:
try:
fcntl.ioctl(outgoing.fileno(), FICLONE, incoming.fileno())
except OSError as error:
if error.errno not in (errno.EXDEV, errno.EOPNOTSUPP, errno.ENOTTY, errno.EINVAL, errno.ENOSYS):
raise
shutil.copyfileobj(incoming, outgoing, length=1024 * 1024)
finally:
os.close(source_fd)
def trusted_staging_root(uid, gid):
base = STAGING_ROOT
info = base.lstat()
ancestors = (base, *base.parents)
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0
or any(p.lstat().st_uid != 0 or
(p.lstat().st_mode & 0o022 and not p.lstat().st_mode & stat.S_ISVTX)
for p in ancestors)
or not all(_allowed(p, uid, gid, 1) for p in ancestors)):
raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(base))
return base
@contextmanager
def import_archive(source, original, uid, gid):
source = Path(source)
if readable(source, uid, gid):
yield original
return
base = trusted_staging_root(uid, gid)
# mkdtemp is 0700 even if the caller's umask is permissive. Publish group
# traversal only after the root-owned, read-only copy is complete.
directory = Path(tempfile.mkdtemp(prefix='proxmenux-oci-import-', dir=base))
try:
target = directory / 'image.tar'
_copy(source.resolve(strict=True), target)
os.chown(target, 0, gid)
target.chmod(0o440)
os.chown(directory, 0, gid)
directory.chmod(0o750)
yield str(target)
finally:
shutil.rmtree(directory)
def create(vmid, original, source, options):
uid, gid = reader_ids(options)
with import_archive(source, original, uid, gid) as archive:
result = subprocess.run(['pct', 'create', vmid, archive, *options],
close_fds=True, umask=0o022)
return 128 - result.returncode if result.returncode < 0 else result.returncode
def main():
# A catchable interruption must unwind the staging context. subprocess.run
# terminates its child when the handler raises; no background pct is orphaned.
for caught in (signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT):
signal.signal(caught, lambda signum, frame: sys.exit(128 + signum))
if sys.platform == 'linux':
parent = os.getppid()
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
if os.getppid() != parent:
return 143
try:
return create(sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4:])
except KeyboardInterrupt:
return 130
except OSError as error:
print(error, file=sys.stderr)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+2 -2
View File
@@ -171,7 +171,7 @@ def plan(record, candidate, config):
else: delete.append('env')
if 'devices' in changed:
inventory = devices.planned(new)
existing = {v['path']: k for k, v in ((k, dict(p.split('=', 1) for p in v.split(',')))
existing = {v['path']: k for k, v in ((k, devices.device_fields(v))
for k, v in cfg.items() if re.fullmatch(r'dev[0-9]+', k))}
used = set(existing.values())
for path, key in existing.items():
@@ -185,7 +185,7 @@ def plan(record, candidate, config):
info = inventory[path]
mode = info['mode'] if device.get('mode') == 'preserve-host' else int(device.get('mode', '0660'), 8)
values[key] = (f"path={path},mode={mode:04o},uid={int(device.get('uid', 0))},"
f"gid={info['gid'] if device['gid_strategy'] == 'host-device-gid' else 0},"
f"gid={devices.container_gid(device, info)},"
f"deny-write={1 if device.get('deny_write') else 0}")
if 'mounts' in changed:
actual = transaction.mounts(config)
+2 -2
View File
@@ -7,7 +7,7 @@ from pathlib import Path, PurePosixPath
import stat
import subprocess
from oci_gpu_devices import actual_devices
from oci_gpu_devices import actual_devices, device_fields
from oci_ui import translate
KEY = '_nvidia_runtime'
@@ -104,7 +104,7 @@ def refresh_plan(config, previous, current=None, same_gpu=True):
if line.startswith('dev') and ': ' in line:
key, properties = line.split(': ', 1)
if key[3:].isdigit():
fields = dict(part.split('=', 1) for part in properties.split(','))
fields = device_fields(properties)
if fields.get('path') in previous['devices']:
continue
kept.append(line)
+1 -1
View File
@@ -585,7 +585,7 @@ class NativeAdapter:
if sha(config) != child.get('staged_config_sha256'):
raise ValueError(translate('The configuration of a new member changed after it was created'))
member_tx.check_runtime_mounts(config, original['deployment'])
member_tx.gpu_devices.check(config, original['deployment'])
member_tx.gpu_devices.check(config, child['candidate_contract']['deployment'])
child['candidate_host_sources'] = member_tx.candidate_host_sources(journal, child)
child['validated_config_sha256'] = sha(config)
instances.write(journal, child)
+3 -2
View File
@@ -127,7 +127,7 @@ oci_create_container() {
total_mib=$(python3 "$VERIFY_OCI_ARCHIVE" --extracted-mib "$path" 2>/dev/null || printf '0')
[[ $total_mib =~ ^[0-9]+$ ]] || total_mib=0
fi
pct create "$vmid" "$archive" "$@" >>"$log" 2>&1 &
python3 "${SCRIPT_DIR}/oci_native_import.py" "$vmid" "$archive" "$path" "$@" >>"$log" 2>&1 &
pid=$!
while kill -0 "$pid" 2>/dev/null; do
if mountpoint -q "$rootfs" 2>/dev/null; then
@@ -208,7 +208,8 @@ oci_apply_extra_devices() {
|| die "$(translate "Invalid device path:") $path"
[[ -c $path ]] || die "$(translate "The character device does not exist:") $path"
[[ $mode =~ ^0?[0-7]{3}$ ]] || die "$(translate "Invalid device mode:") $mode"
pct config "$vmid" | grep -Eq "^dev[0-9]+: (.*,)?path=${path}(,|$)" && continue
# Proxmox may write the path without `path=` when it was added from its interface.
pct config "$vmid" | grep -Eq "^dev[0-9]+: ((.*,)?path=)?${path}(,|$)" && continue
index=0
while pct config "$vmid" | grep -q "^dev${index}:"; do index=$((index + 1)); done
gid=$(stat -c '%g' "$path")
+2 -2
View File
@@ -1610,7 +1610,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
shutil.copy2(project_root / 'remote' / 'oci_image_mountpoints.py', temporary / 'oci_image_mountpoints.py')
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_native_import.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1647,7 +1647,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_carried_record.py', 'oci_compose_jobs.py', 'oci_stack_public_routes.py', 'oci_ui.sh', 'oci_ui.py', 'oci_native_import.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
@@ -0,0 +1,77 @@
"""A device added from the Proxmox interface is written with its path as the
first value, without `path=`. Every reader of the container configuration
accepts both forms."""
from pathlib import Path
import re
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_gpu_devices as devices # noqa: E402
import oci_instance_reconcile as reconcile # noqa: E402
class DeviceFieldsTests(unittest.TestCase):
def test_both_forms_give_the_same_fields(self):
expected = {'path': '/dev/dri/renderD128', 'gid': '993'}
self.assertEqual(devices.device_fields('/dev/dri/renderD128,gid=993'), expected)
self.assertEqual(devices.device_fields('path=/dev/dri/renderD128,gid=993'), expected)
self.assertEqual(devices.device_fields('/dev/apex_0'), {'path': '/dev/apex_0'})
def test_a_value_without_name_after_the_first_is_refused(self):
for value in ('/dev/dri/renderD128,993', 'gid=993,/dev/dri/renderD128', ''):
with self.subTest(value=value), self.assertRaises(ValueError):
devices.device_fields(value)
def test_actual_devices_reads_a_line_written_by_proxmox(self):
config = (b'arch: amd64\n'
b'dev0: /dev/dri/renderD128,gid=993\n'
b'dev1: path=/dev/apex_0,mode=0660\n')
self.assertEqual(devices.actual_devices(config), {
'/dev/dri/renderD128': {'path': '/dev/dri/renderD128', 'gid': '993'},
'/dev/apex_0': {'path': '/dev/apex_0', 'mode': '0660'},
})
def test_the_same_device_in_both_forms_is_a_duplicate(self):
config = b'dev0: /dev/dri/renderD128\ndev1: path=/dev/dri/renderD128\n'
with self.assertRaises(ValueError):
devices.actual_devices(config)
def test_adoption_names_the_line_it_cannot_read(self):
with self.assertRaisesRegex(ValueError, '^dev2: '):
reconcile._device('dev2', '/dev/dri/renderD128,993')
class NoPairParsingLeftTests(unittest.TestCase):
def test_no_reader_builds_the_fields_from_pairs_alone(self):
pattern = re.compile(r"dict\(\s*\w+\.split\('=', 1\) for \w+ in .*split\(','\)")
for path in sorted((ROOT / 'remote').glob('*.py')):
source = path.read_text()
for line in source.splitlines():
if 'dev' in line or 'properties' in line:
with self.subTest(file=path.name):
self.assertIsNone(pattern.search(line), line)
class ShellDuplicateCheckTests(unittest.TestCase):
def check(self, config, path):
script = ('pct() { printf "%s" "$CONFIG"; }\n'
f'pct config 1 | grep -Eq "^dev[0-9]+: ((.*,)?path=)?{path}(,|$)"')
return subprocess.run(['bash', '-c', script], env={'CONFIG': config, 'PATH': '/usr/bin:/bin'}).returncode
def test_the_installer_sees_a_device_already_added_in_either_form(self):
self.assertEqual(self.check('dev0: /dev/ttyUSB0,gid=20\n', '/dev/ttyUSB0'), 0)
self.assertEqual(self.check('dev0: mode=0660,path=/dev/ttyUSB0\n', '/dev/ttyUSB0'), 0)
self.assertEqual(self.check('dev0: /dev/ttyUSB1\n', '/dev/ttyUSB0'), 1)
self.assertEqual(self.check('dev0: /dev/ttyUSB01\n', '/dev/ttyUSB0'), 1)
def test_the_installer_uses_that_check(self):
source = (ROOT / 'remote/oci_ui.sh').read_text()
self.assertIn('grep -Eq "^dev[0-9]+: ((.*,)?path=)?${path}(,|$)"', source)
if __name__ == '__main__':
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""Keep recorded LXC device-node GIDs distinct from host device ownership."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_gpu_devices as devices
import oci_accelerators as accelerators
HOST = {
'path': '/dev/dri/renderD128', 'major': 226, 'minor': 128,
'uid': 0, 'gid': 993, 'mode': 0o660,
'vendor': '0x8086', 'pci_path': '/sys/devices/pci0000:00/0000:00:02.0',
}
DEPLOYMENT = {'devices': [{
'kind': 'character-device', 'host_path': '/dev/dri/renderD128',
'container_path': '/dev/dri/renderD128', 'gid_strategy': 'host-device-gid',
'mode': '0660', 'drm_vendor_ids': ['0x8086', '0x1002'],
}]}
SAVED_CONFIG = 'dev0: path=/dev/dri/renderD128,mode=0660,deny-write=0,gid=44\n'
CURRENT_CONFIG = SAVED_CONFIG.encode()
class ContainerDeviceGidTests(unittest.TestCase):
@patch.object(devices, 'snapshot', return_value=HOST)
def test_recorded_guest_gid_is_carried_to_update_and_still_checks_host_identity(self, _snapshot):
record = {'deployment': {'devices': [dict(DEPLOYMENT['devices'][0])]},
'observed': {'config': SAVED_CONFIG}}
candidate = {'deployment': {'devices': [dict(DEPLOYMENT['devices'][0])]}}
devices.preserve_recorded_container_gids(record, candidate, CURRENT_CONFIG)
self.assertEqual(record['deployment']['devices'][0]['container_gid'], 44)
self.assertEqual(candidate['deployment']['devices'][0]['container_gid'], 44)
self.assertEqual(devices.check(CURRENT_CONFIG, record['deployment'])['/dev/dri/renderD128'], HOST)
self.assertEqual(devices.check(CURRENT_CONFIG, candidate['deployment'])['/dev/dri/renderD128'], HOST)
@patch.object(devices, 'snapshot', return_value=HOST)
def test_a_guest_gid_changed_in_proxmox_is_kept(self, _snapshot):
record = {'deployment': {'devices': [dict(DEPLOYMENT['devices'][0], container_gid=44)]},
'observed': {'config': SAVED_CONFIG}}
candidate = {'deployment': {'devices': [dict(DEPLOYMENT['devices'][0])]}}
# Written from the Proxmox interface, without `path=`.
changed = b'dev0: /dev/dri/renderD128,mode=0660,deny-write=0,gid=45\n'
devices.preserve_recorded_container_gids(record, candidate, changed)
self.assertEqual(record['deployment']['devices'][0]['container_gid'], 45)
self.assertEqual(candidate['deployment']['devices'][0]['container_gid'], 45)
self.assertEqual(devices.check(changed, candidate['deployment'])['/dev/dri/renderD128'], HOST)
@patch.object(devices, 'snapshot', return_value=HOST)
def test_other_permission_changes_are_still_refused(self, _snapshot):
record = {'deployment': {'devices': [dict(DEPLOYMENT['devices'][0])]},
'observed': {'config': SAVED_CONFIG}}
candidate = {'deployment': {'devices': [dict(DEPLOYMENT['devices'][0])]}}
changed = CURRENT_CONFIG.replace(b'mode=0660', b'mode=0666')
with self.assertRaisesRegex(ValueError, 'device settings changed'):
devices.preserve_recorded_container_gids(record, candidate, changed)
@patch.object(devices, 'snapshot', return_value=HOST)
def test_host_gid_is_not_automatically_mapped_to_guest_gid_44(self, _snapshot):
with self.assertRaisesRegex(ValueError, 'native GPU permissions were not kept'):
devices.check(CURRENT_CONFIG, DEPLOYMENT)
# Under Proxmox's default unprivileged map, guest GID 44 maps to host
# GID 100044; host GID 993 is outside that map.
self.assertEqual(100000 + 44, 100044)
self.assertFalse(100000 <= 993 < 165536)
@patch.object(devices, 'snapshot', return_value=HOST)
def test_changed_host_device_identity_or_permissions_still_fail_baseline(self, snapshot):
baseline = {'/dev/dri/renderD128': HOST}
current = dict(HOST, gid=992)
snapshot.return_value = current
with self.assertRaisesRegex(ValueError, 'GPU identity or permissions changed'):
accelerators.verify_baseline(baseline, DEPLOYMENT)
def test_container_gid_must_be_a_bounded_integer(self):
for invalid in (True, -1, 4294967295, '44'):
with self.subTest(invalid=invalid), self.assertRaisesRegex(ValueError, 'Invalid container device GID'):
devices.container_gid({'gid_strategy': 'host-device-gid', 'container_gid': invalid}, HOST)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,34 @@
"""The startup check asks for the address a browser would use: without the
default port of its scheme. Nextcloud trusts the host of its first request."""
from pathlib import Path
import re
import subprocess
import unittest
SCRIPT = (Path(__file__).resolve().parents[1] / 'remote/install_oci.sh').read_text()
def url(scheme, port, path='/'):
function = re.search(r'^healthcheck_url\(\) \{\n.*?^\}\n', SCRIPT, re.M | re.S).group(0)
done = subprocess.run(['bash', '-c', function + f'HC_SCHEME={scheme} IP=192.0.2.10 HC_PATH={path}; healthcheck_url {port}'],
capture_output=True, text=True, check=True)
return done.stdout
class HealthcheckUrlTests(unittest.TestCase):
def test_default_ports_are_left_out(self):
self.assertEqual(url('https', 443), 'https://192.0.2.10/')
self.assertEqual(url('http', 80, '/status'), 'http://192.0.2.10/status')
def test_other_ports_are_kept(self):
self.assertEqual(url('http', 443), 'http://192.0.2.10:443/')
self.assertEqual(url('https', 8443), 'https://192.0.2.10:8443/')
self.assertEqual(url('http', 3000), 'http://192.0.2.10:3000/')
def test_every_probe_address_uses_it(self):
self.assertIn('HC_URL=$(healthcheck_url "$HC_PORT")', SCRIPT)
self.assertIn('HC_URLS+=("$(healthcheck_url "$HC_ALT_PORT")")', SCRIPT)
if __name__ == '__main__':
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""PBS backup traversal modes must not loosen the OCI caller's private files."""
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import textwrap
import unittest
REMOTE = Path(__file__).resolve().parents[1] / 'remote'
class KeptBackupPermissions(unittest.TestCase):
def scenario(self, code, exit_code=0):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
binary = root / 'vzdump'
binary.write_text(f'#!{sys.executable}\n' + textwrap.dedent(f'''
import os
from pathlib import Path
import sys
assert sys.argv[1:] == ['4242', '--storage', 'pbs-fixture', '--mode',
'snapshot', '--compress', 'zstd', '--notes-template',
'ProxMenux OCI: before the image update']
root = Path(os.environ['BACKUP_FIXTURE'])
(root / 'pve-tmp').mkdir()
(root / 'pve-tmp/pct.conf').write_text('fixture only')
sys.exit({exit_code})
'''))
binary.chmod(0o700)
script = textwrap.dedent('''
import os
from pathlib import Path
import stat
import subprocess
import sys
sys.path.insert(0, sys.argv[1])
import oci_instances as instances
import oci_keep_backup as backup
root = Path(os.environ['BACKUP_FIXTURE'])
backup.dump_dir = lambda storage: None
instances.write(root / 'private-before/record.json', {'secret': 'fixture'})
''') + textwrap.dedent(code)
result = subprocess.run(
[sys.executable, '-c', script, str(REMOTE)],
env={**os.environ, 'PATH': str(root) + os.pathsep + os.environ['PATH'],
'BACKUP_FIXTURE': str(root)},
umask=0o077, capture_output=True, text=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_pbs_child_is_readable_while_parent_records_stay_private(self):
self.scenario('''
assert backup.before_update(4242, 'pbs-fixture') == 'pbs-fixture'
assert stat.S_IMODE((root / 'pve-tmp').stat().st_mode) == 0o755
assert stat.S_IMODE((root / 'pve-tmp/pct.conf').stat().st_mode) == 0o644
assert os.umask(0o077) == 0o077
instances.write(root / 'private-after/record.json', {'secret': 'fixture'})
for name in ('private-before', 'private-after'):
assert stat.S_IMODE((root / name).stat().st_mode) == 0o700
assert stat.S_IMODE((root / name / 'record.json').stat().st_mode) == 0o600
''')
def test_backup_failure_still_propagates_without_changing_parent_umask(self):
self.scenario('''
try:
backup.before_update(4242, 'pbs-fixture')
except subprocess.CalledProcessError as error:
assert error.returncode == 7
else:
raise AssertionError('backup failure was hidden')
assert os.umask(0o077) == 0o077
assert stat.S_IMODE((root / 'private-before/record.json').stat().st_mode) == 0o600
''', exit_code=7)
def test_file_storage_keeps_the_existing_no_extra_backup_route(self):
self.scenario('''
backup.dump_dir = lambda storage: root / 'dump'
assert backup.before_update(4242, 'pbs-fixture') is None
assert not (root / 'pve-tmp').exists()
assert os.umask(0o077) == 0o077
''')
if __name__ == '__main__':
unittest.main()
+2 -1
View File
@@ -18,7 +18,8 @@ class MainFrontendReadiness(unittest.TestCase):
with patch.object(installer,'run',return_value='10.77.6.30\n172.30.249.70\n'), \
patch.object(installer,'wait_web') as web:
url=installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
self.assertEqual(url,'http://172.30.249.70:80/')
# Without the default port: the host a browser sends.
self.assertEqual(url,'http://172.30.249.70/')
self.assertEqual(service['healthcheck']['url'],url)
self.assertEqual(service['ip'],'10.77.6.30')
web.assert_called_once_with(service,url)
+181
View File
@@ -0,0 +1,181 @@
"""Native mapped readers may read an image without exposing its private cache."""
import errno
import importlib.util
import os
from pathlib import Path
import signal
import stat
import subprocess
import sys
import tempfile
import time
import types
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('native_import_access', ROOT / 'remote/oci_native_import.py')
imports = importlib.util.module_from_spec(spec)
spec.loader.exec_module(imports)
class PrivateImageImportTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.root = Path(self.directory.name)
self.source = self.root / 'private-image.tar'
self.source.write_bytes(b'OCI image fixture\x00private layers')
self.source.chmod(0o600)
def staging(self):
return patch.object(imports, 'trusted_staging_root', return_value=self.root)
def test_normal_readable_image_uses_original_storage_volume_without_copy(self):
with patch.object(imports, 'readable', return_value=True), \
patch.object(imports, '_copy', side_effect=AssertionError('must stay zero-copy')):
with imports.import_archive(self.source, 'local:vztmpl/public.tar', 100000, 100000) as path:
self.assertEqual(path, 'local:vztmpl/public.tar')
def test_private_copy_preserves_source_and_limits_mapped_reader_group(self):
before = self.source.stat()
with self.staging(), patch.object(imports.os, 'chown') as chown, \
patch.object(imports, 'readable', return_value=False):
with imports.import_archive(self.source, str(self.source), 100000, 100000) as path:
staged = Path(path)
self.assertEqual(staged.read_bytes(), self.source.read_bytes())
self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o440)
self.assertEqual(stat.S_IMODE(staged.parent.stat().st_mode), 0o750)
chown.assert_any_call(staged, 0, 100000)
chown.assert_any_call(staged.parent, 0, 100000)
self.assertFalse(staged.parent.exists())
after = self.source.stat()
self.assertEqual((before.st_uid, before.st_gid, before.st_mode),
(after.st_uid, after.st_gid, after.st_mode))
def test_copy_error_cleans_stage_and_never_invokes_pct(self):
with self.staging(), patch.object(imports, 'readable', return_value=False), \
patch.object(imports, '_copy', side_effect=OSError(errno.ENOSPC, 'fixture')), \
patch.object(imports.subprocess, 'run') as run:
with self.assertRaises(OSError):
imports.create('9161', str(self.source), str(self.source), ['--unprivileged', '1'])
run.assert_not_called()
self.assertEqual(list(self.root.iterdir()), [self.source])
def test_pct_failure_and_interrupt_both_clean_stage(self):
for error in (None, KeyboardInterrupt()):
with self.staging(), patch.object(imports.os, 'chown'), \
patch.object(imports, 'readable', return_value=False), \
patch.object(imports.subprocess, 'run', side_effect=error,
return_value=types.SimpleNamespace(returncode=23)) as run:
if error:
with self.assertRaises(KeyboardInterrupt):
imports.create('9161', str(self.source), str(self.source), ['--unprivileged', '1'])
else:
self.assertEqual(imports.create('9161', str(self.source), str(self.source),
['--unprivileged', '1']), 23)
self.assertEqual(run.call_args.kwargs['umask'], 0o022)
self.assertEqual(list(self.root.iterdir()), [self.source])
def test_standard_map_and_privileged_profile_initial_unprivileged_create(self):
for options in (['--unprivileged', '1'], ['--unprivileged=1'], ['--unprivileged', 'true']):
self.assertEqual(imports.reader_ids(options), (100000, 100000))
self.assertEqual(imports.reader_ids(['--unprivileged', '0']), (0, 0))
installer = (ROOT / 'remote/install_oci.sh').read_text()
self.assertIn('CREATE_UNPRIVILEGED_FLAG=1', installer)
self.assertIn('--unprivileged "$CREATE_UNPRIVILEGED_FLAG"', installer)
def test_derived_numeric_user_image_is_staged_without_mutating_its_source(self):
derived = self.root / 'image.user-databases.123.tar'
derived.write_bytes(b'derived OCI image')
derived.chmod(0o600)
with self.staging(), patch.object(imports.os, 'chown'), \
patch.object(imports, 'readable', return_value=False):
with imports.import_archive(derived, str(derived), 100000, 100000) as path:
self.assertEqual(Path(path).read_bytes(), derived.read_bytes())
self.assertEqual(stat.S_IMODE(derived.stat().st_mode), 0o600)
def test_reflink_unavailable_falls_back_to_an_identical_copy(self):
target = self.root / 'copy.tar'
with patch.object(imports.fcntl, 'ioctl', side_effect=OSError(errno.EOPNOTSUPP, 'fixture')):
imports._copy(self.source, target)
self.assertEqual(target.read_bytes(), self.source.read_bytes())
def test_local_and_ssh_installer_stage_the_native_helper(self):
import ast
tree = ast.parse((ROOT / 'src/proxmenux_oci/installer.py').read_text())
function = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_run_remote_install')
lists = [n.iter for n in ast.walk(function) if isinstance(n, ast.For)
and isinstance(n.iter, ast.Tuple)
and any(isinstance(v, ast.Constant) and v.value == 'oci_ui.sh' for v in n.iter.elts)]
self.assertEqual(len(lists), 2)
for values in lists:
self.assertIn('oci_native_import.py', [v.value for v in values.elts])
self.assertIn('oci_native_import.py', (ROOT / 'remote/oci_ui.sh').read_text())
@unittest.skipUnless(sys.platform == 'linux' and os.geteuid() == 0,
'isolated mapped UID/GID proof runs as root in Linux CI')
class MappedReaderProof(unittest.TestCase):
def reader(self, path, uid, gid):
return subprocess.run([sys.executable, '-c',
'import os,sys; os.setgroups([]); os.setgid(int(sys.argv[2])); '
'os.setuid(int(sys.argv[1])); print(open(sys.argv[3],"rb").read().decode())',
str(uid), str(gid), str(path)], capture_output=True, text=True)
def test_mapped_reader_only_private_source_and_caller_state_are_preserved(self):
with tempfile.TemporaryDirectory(dir='/var/tmp') as folder:
root = Path(folder)
root.chmod(0o700)
source = root / 'private.tar'
source.write_text('private image contents')
source.chmod(0o600)
self.assertNotEqual(self.reader(source, 100000, 100000).returncode, 0)
previous = os.umask(0o077)
try:
with imports.import_archive(source, str(source), 100000, 100000) as staged:
path = Path(staged)
self.assertEqual(self.reader(path, 100000, 100000).stdout.strip(), 'private image contents')
self.assertNotEqual(self.reader(path, 100001, 100001).returncode, 0)
self.assertFalse(path.stat().st_mode & 0o022)
self.assertEqual(path.stat().st_uid, 0)
self.assertFalse(path.parent.exists())
record = root / 'record.json'
record.write_text('private credentials fixture')
self.assertEqual(stat.S_IMODE(record.stat().st_mode), 0o600)
self.assertEqual(os.umask(0o077), 0o077)
self.assertEqual(stat.S_IMODE(source.stat().st_mode), 0o600)
self.assertEqual(stat.S_IMODE(root.stat().st_mode), 0o700)
finally:
os.umask(previous)
def test_sigterm_removes_staging_and_preserves_native_output_and_status(self):
with tempfile.TemporaryDirectory(dir='/var/tmp') as folder:
root = Path(folder)
source = root / 'private.tar'; source.write_text('private image'); source.chmod(0o600)
pct = root / 'pct'
pct.write_text('#!/bin/sh\nmkdir "$FIXTURE/pve-tmp"\ntouch "$FIXTURE/pve-tmp/pct.conf"\nprintf "%s\\n" "$3" >"$MARKER"\necho native-output\nexec sleep 60\n')
pct.chmod(0o700)
marker = root / 'marker'
env = dict(os.environ, PATH=str(root) + ':' + os.environ['PATH'], MARKER=str(marker), FIXTURE=str(root))
process = subprocess.Popen([sys.executable, str(ROOT / 'remote/oci_native_import.py'),
'9161', str(source), str(source), '--unprivileged', '1'], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, umask=0o077)
try:
for _ in range(100):
if marker.exists(): break
time.sleep(0.05)
self.assertTrue(marker.exists())
staged = Path(marker.read_text().strip())
self.assertTrue(staged.exists())
self.assertEqual(stat.S_IMODE((root / "pve-tmp").stat().st_mode), 0o755)
self.assertEqual(stat.S_IMODE((root / "pve-tmp/pct.conf").stat().st_mode), 0o644)
process.send_signal(signal.SIGTERM)
# Do not leave a shell sleep grandchild holding capture pipes open.
output, error = process.communicate(timeout=5)
self.assertIn("native-output", output)
self.assertEqual(process.returncode, 143)
self.assertFalse(staged.parent.exists())
finally:
if process.poll() is None: process.kill(); process.wait()
process.stdout.close(); process.stderr.close()
+214
View File
@@ -0,0 +1,214 @@
"""The LinuxServer/MariaDB variant keeps its complete private stack and safe bootstrap."""
import json
import base64
import re
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from contextlib import ExitStack
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(Path(__file__).parent)]
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.custom import template_from_oci_compose
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
from proxmenux_oci.stack import assess
from test_advanced_flow_order import RecordingUI, storages, addresses
class NextcloudMariaDBTests(unittest.TestCase):
def setUp(self):
self.catalog = Catalog(ROOT)
self.template = self.catalog.compose('nextcloud-mariadb')
def test_catalog_roundtrip_preserves_the_original_image_families_and_tls(self):
t = template_from_oci_compose(json.dumps(self.template))
self.catalog.validate(t)
self.assertEqual(assess(t), [])
services = {s['name']: s for s in t['compose_stack']['services']}
self.assertEqual({k: v['image'] for k, v in services.items()}, {
'nextcloud': 'lscr.io/linuxserver/nextcloud:latest',
'db': 'mariadb:10.11', 'redis': 'redis:7-alpine'})
self.assertEqual(t['first_run']['endpoints'][0]['scheme'], 'https')
self.assertEqual(t['first_run']['endpoints'][0]['port'], 443)
self.assertEqual(t['first_run']['credentials'][0]['password_environment'], 'NEXTCLOUD_ADMIN_PASSWORD')
self.assertEqual(t['first_run']['credentials'][0]['username'], 'admin')
self.assertTrue(t['catalog_ui']['icon'].startswith('https://'))
self.assertEqual(set(services['nextcloud']['depends_on']), {'db', 'redis'})
for name in ('db', 'redis'):
self.assertFalse(services[name]['compose'].get('ports'))
self.assertFalse(services[name]['frontend_network'])
def plan(self, mode, answers=None):
with ExitStack() as stack:
for target, value in [
('proxmenux_oci.installer.host.storages', storages),
('proxmenux_oci.installer.access.ask_addresses', addresses)]:
stack.enter_context(patch(target, side_effect=value))
for target, value in [
('proxmenux_oci.i18n.language', 'en'),
('proxmenux_oci.installer.host.bridges', [{'iface': 'vmbr0', 'cidr': '192.0.2.1/24'}]),
('proxmenux_oci.installer.host.timezone', 'Europe/Madrid')]:
stack.enter_context(patch(target, return_value=value))
return build_deployment(self.template, RecordingUI(answers), mode)
def test_default_and_advanced_plans_keep_shared_secrets_private_dependencies_and_persistence(self):
for mode in (DEFAULT_MODE, ADVANCED_MODE):
with self.subTest(mode=mode):
plan = self.plan(mode)
services = {s['name']: s for s in plan['services']}
env = {name: {e['name']: e['value'] for e in s['deployment']['environment']}
for name, s in services.items()}
self.assertEqual(env['nextcloud']['MYSQL_PASSWORD'], env['db']['MYSQL_PASSWORD'])
self.assertNotEqual(env['db']['MYSQL_PASSWORD'], env['db']['MYSQL_ROOT_PASSWORD'])
# The administrator is created by ProxMenux, not by whoever opens the page first.
self.assertEqual(len(env['nextcloud']['NEXTCLOUD_ADMIN_PASSWORD']), 64)
self.assertNotIn(env['nextcloud']['NEXTCLOUD_ADMIN_PASSWORD'],
(env['db']['MYSQL_PASSWORD'], env['db']['MYSQL_ROOT_PASSWORD']))
self.assertNotIn('NEXTCLOUD_ADMIN_PASSWORD', env['db'])
self.assertEqual(env['nextcloud']['MYSQL_HOST'], 'db')
self.assertEqual([s['name'] for s in plan['services']], ['db', 'redis', 'nextcloud'])
self.assertEqual([s['name'] for s in plan['services'] if s.get('frontend')], ['nextcloud'])
self.assertEqual(services['db']['healthcheck']['argv'][0], 'healthcheck.sh')
self.assertEqual(services['nextcloud']['healthcheck']['endpoint']['scheme'], 'https')
self.assertEqual({k: s['deployment']['hostname'] for k, s in services.items()},
{'nextcloud': 'nextcloud', 'db': 'nextcloud-db', 'redis': 'nextcloud-redis'})
for service in services.values():
self.assertTrue(service['deployment']['security']['unprivileged'])
self.assertTrue(all(m['backup'] for m in service['deployment']['mounts']))
self.assertEqual({k: v['deployment']['resources']['memory_mb'] for k, v in services.items()},
{'nextcloud': 2048, 'db': 1024, 'redis': 512})
runtime = services['nextcloud']['template']['proxmox']['installer_profile']['runtime']
self.assertIn('exec /init', runtime['compose_entrypoint'][2])
# The private umask of the bootstrap does not reach the application.
self.assertTrue(runtime['compose_entrypoint'][2].startswith('(umask 077; '))
self.assertTrue(runtime['compose_entrypoint'][2].endswith('| php); exec /init'))
self.assertNotIn('\n', runtime['compose_entrypoint'][2])
self.assertNotIn('\r', runtime['compose_entrypoint'][2])
def test_nas_mount_is_separate_from_configuration_database_and_cache(self):
plan = self.plan(DEFAULT_MODE, {'Where to store nextcloud: /data': 'host-bind',
'Host path for nextcloud: /data': '/mnt/pve/media/Nextcloud'})
services = {s['name']: s for s in plan['services']}
mounts = {m['container_path']: m for m in services['nextcloud']['deployment']['mounts']}
self.assertEqual(mounts['/data']['source'], '/mnt/pve/media/Nextcloud')
self.assertEqual(mounts['/data']['type'], 'host-bind')
self.assertFalse(mounts['/data']['backup'])
self.assertEqual(mounts['/config']['type'], 'managed-volume')
for name in ('db', 'redis'):
self.assertTrue(all(m['type'] == 'managed-volume' and m['backup']
for m in services[name]['deployment']['mounts']))
@unittest.skipUnless(shutil.which('php'), 'PHP CLI is needed to execute the bootstrap contract')
def test_bootstrap_prefills_mysql_with_escaped_secrets_and_preserves_existing_files(self):
entry = next(s for s in self.template['compose_stack']['services'] if s['name'] == 'nextcloud')['compose']['entrypoint']
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp) / 'config'
image_config = Path(temp) / 'image-config'
image_config.mkdir()
defaults = {'CAN_INSTALL': '', '.htaccess': 'deny all', 'config.sample.php': '<?php // sample'}
for name, content in defaults.items(): (image_config / name).write_text(content)
payload = re.search(r'printf %s ([A-Za-z0-9+/=]+) \| base64 -d \| php', entry[2]).group(1)
source = base64.b64decode(payload).decode()
self.assertEqual(source, (ROOT / 'examples/nextcloud-mariadb-bootstrap.php').read_text())
test_source = source.replace('/config/www/nextcloud/config', str(folder)).replace('/app/www/src/config', str(image_config))
script = entry[2].replace(payload, base64.b64encode(test_source.encode()).decode()).replace('exec /init', 'exit 0')
password = "test-'quote\\backslash$not-a-real-secret\nline"
env = dict(os.environ, MYSQL_HOST='db', MYSQL_DATABASE='nextcloud', MYSQL_USER='nextcloud', MYSQL_PASSWORD=password,
NEXTCLOUD_ADMIN_PASSWORD='admin-' + password)
subprocess.run(['/bin/sh', '-ec', script], env=env, check=True, capture_output=True)
for name, content in defaults.items(): self.assertEqual((folder / name).read_text(), content)
autoconfig = folder / 'autoconfig.php'
redis = folder / 'proxmenux-redis.config.php'
for path in (autoconfig, redis):
subprocess.run(['php', '-l', str(path)], check=True, capture_output=True)
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
code = 'require $argv[1]; echo json_encode($AUTOCONFIG);'
values = json.loads(subprocess.check_output(['php', '-r', code, str(autoconfig)], env=env))
self.assertEqual(values['dbpass'], password)
self.assertEqual(values['dbhost'], 'db')
self.assertEqual(values['directory'], '/data')
self.assertEqual((values['adminlogin'], values['adminpass']), ('admin', 'admin-' + password))
cache = json.loads(subprocess.check_output(['php', '-r', 'require $argv[1]; echo json_encode($CONFIG);', str(redis)]))
self.assertEqual(cache['redis']['host'], 'redis')
self.assertEqual(cache['memcache.locking'], '\\OC\\Memcache\\Redis')
# A retry/update must not replace user edits or a completed installation.
saved = {p: p.read_bytes() for p in (autoconfig, redis)}
subprocess.run(['/bin/sh', '-ec', script], env=dict(env, MYSQL_PASSWORD='changed'), check=True, capture_output=True)
for path, content in saved.items(): self.assertEqual(path.read_bytes(), content)
autoconfig.unlink()
(folder / 'CAN_INSTALL').unlink()
(folder / 'config.php').write_text('<?php $CONFIG = ["installed" => true];')
subprocess.run(['/bin/sh', '-ec', script], env=env, check=True, capture_output=True)
self.assertFalse(autoconfig.exists())
self.assertFalse((folder / 'CAN_INSTALL').exists())
@unittest.skipUnless(shutil.which('php'), 'PHP CLI is needed to execute the bootstrap contract')
def test_bootstrap_refuses_links_and_needs_the_administrator_password(self):
source = (ROOT / 'examples/nextcloud-mariadb-bootstrap.php').read_text()
base = dict(os.environ, MYSQL_HOST='db', MYSQL_DATABASE='nextcloud', MYSQL_USER='nextcloud', MYSQL_PASSWORD='x')
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp) / 'config'
folder.mkdir()
target = Path(temp) / 'outside'
(folder / 'autoconfig.php').symlink_to(target)
script = source.replace('/config/www/nextcloud/config', str(folder)).replace('/app/www/src/config', str(Path(temp) / 'none'))
done = subprocess.run(['php'], input=script.encode(), env=dict(base, NEXTCLOUD_ADMIN_PASSWORD='y'), capture_output=True)
self.assertNotEqual(done.returncode, 0)
self.assertFalse(target.exists())
(folder / 'autoconfig.php').unlink()
done = subprocess.run(['php'], input=script.encode(), env=base, capture_output=True)
self.assertNotEqual(done.returncode, 0)
self.assertFalse((folder / 'autoconfig.php').exists())
@unittest.skipUnless(sys.platform.startswith('linux') and os.geteuid() == 0 and shutil.which('php'),
'Linux root is needed to reproduce existing-volume ownership')
def test_existing_config_volume_can_read_new_redis_configuration_as_its_app_user(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
root.chmod(0o755)
folder = root / 'config'
folder.mkdir(mode=0o755)
os.chown(folder, 1001, 1001)
installed = folder / 'autoconfig.php'
installed.write_text('<?php $CONFIG = ["installed" => true];')
installed.chmod(0o640)
os.chown(installed, 1001, 1001)
source = (ROOT / 'examples/nextcloud-mariadb-bootstrap.php').read_text()
source = source.replace('/config/www/nextcloud/config', str(folder))
subprocess.run(['php'], input=source.encode(), check=True, capture_output=True)
redis = folder / 'proxmenux-redis.config.php'
self.assertEqual((redis.stat().st_uid, redis.stat().st_gid), (1001, 1001))
self.assertEqual(redis.stat().st_mode & 0o777, 0o600)
def app_user():
os.setgroups([])
os.setgid(1001)
os.setuid(1001)
result = subprocess.check_output(['php', '-r',
'require $argv[1]; echo json_encode($CONFIG);', str(redis)], preexec_fn=app_user)
self.assertEqual(json.loads(result)['redis']['host'], 'redis')
self.assertEqual(installed.read_text(), '<?php $CONFIG = ["installed" => true];')
self.assertFalse((folder / 'config.php').exists())
@unittest.skipUnless(shutil.which('php'), 'PHP CLI needed for the installed-instance contract')
def test_existing_installation_does_not_override_user_cache_settings(self):
with tempfile.TemporaryDirectory() as temp:
folder = Path(temp) / 'config'
folder.mkdir()
config = folder / 'config.php'
content = '<?php $CONFIG = ["installed" => true, "redis" => ["host" => "user-cache", "timeout" => 3]];'
config.write_text(content)
source = (ROOT / 'examples/nextcloud-mariadb-bootstrap.php').read_text()
source = source.replace('/config/www/nextcloud/config', str(folder))
subprocess.run(['php'], input=source.encode(), check=True, capture_output=True)
self.assertEqual(config.read_text(), content)
self.assertFalse((folder / 'proxmenux-redis.config.php').exists())
self.assertFalse((folder / 'autoconfig.php').exists())
if __name__ == '__main__':
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
"""Real self-signed image HTTPS readiness, without weakening strict probes."""
import copy
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import shutil
import ssl
import subprocess
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import install_generic_stack as installer
@unittest.skipUnless(shutil.which('openssl'), 'openssl needed for local TLS fixture')
class HTTPSReadiness(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory = tempfile.TemporaryDirectory()
cert, key = (Path(cls.directory.name) / name for name in ('cert.pem', 'key.pem'))
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', str(key), '-out', str(cert), '-days', '1',
'-subj', '/CN=localhost'], check=True, capture_output=True)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(503 if self.path == '/unhealthy' else 200)
self.end_headers()
def log_message(self, *args):
pass
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(cert, key)
cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
cls.thread.start()
cls.url = f'https://127.0.0.1:{cls.server.server_port}'
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.server.server_close()
cls.thread.join()
cls.directory.cleanup()
def probe(self, *, verify_tls=False, path='/'):
service = {'vmid': 9070, 'name': 'test', 'healthcheck': {
'type': 'http', 'timeout_seconds': 1, 'verify_tls': verify_tls}}
# Only Proxmox command discovery is mocked; TLS and HTTP are real.
with patch.object(installer, 'run', return_value='status: running'), \
patch('oci_installation_state.container_curl', return_value=['curl']):
installer.wait_web(service, self.url + path)
def test_image_generated_certificate_passes_readiness(self):
self.probe()
def test_explicit_strict_tls_still_rejects_self_signed_certificate(self):
with self.assertRaisesRegex(RuntimeError, 'HTTP check'):
self.probe(verify_tls=True)
def test_tls_tolerance_does_not_accept_http_failure(self):
with self.assertRaisesRegex(RuntimeError, 'HTTP check'):
self.probe(path='/unhealthy')
class NamespaceTLSReadiness(unittest.TestCase):
def test_vlan_curl_obeys_the_same_verification_policy(self):
for strict in (False, True):
with self.subTest(strict=strict), \
patch('oci_installation_state.container_curl', return_value=['nsenter', '--net=/proc/123/ns/net', 'curl']), \
patch.object(installer, 'run') as run:
installer.web_answers(9070, 'https://192.0.2.7/', None, verify_tls=strict)
args = run.call_args.args
self.assertEqual('--insecure' in args, not strict)
self.assertIn('-fsS', args)
self.assertIn('--noproxy', args)
if __name__ == '__main__':
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
"""Tailscale keeps starting once its web client is on: the LAN interface listens
on the address of eth0, not on 0.0.0.0, and subnet routes get forwarding."""
import copy
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_instance_transaction as transaction # noqa: E402
def recipe():
return json.loads((ROOT / 'catalog/apps/tailscale.json').read_text())
class RecipeTests(unittest.TestCase):
def test_the_recipe_and_its_overlay_carry_the_current_command(self):
template = recipe()
self.assertEqual(template['proxmox']['installer_profile']['runtime']['compose_entrypoint'],
transaction.TAILSCALE_ENTRYPOINT)
self.assertEqual(template['compose_stack']['services'][0]['compose']['entrypoint'],
transaction.TAILSCALE_ENTRYPOINT)
overlay = json.loads((ROOT / 'catalog/overlays/tailscale.json').read_text())
self.assertEqual(overlay['proxmox']['installer_profile']['runtime']['compose_entrypoint'],
transaction.TAILSCALE_ENTRYPOINT)
def test_a_saved_recipe_with_the_previous_command_gets_the_current_one(self):
template = recipe()
template['proxmox']['installer_profile']['runtime']['compose_entrypoint'] = \
transaction.TAILSCALE_PREVIOUS_ENTRYPOINT
contract = transaction.with_default_healthcheck({'template': template})
self.assertEqual(contract['template']['proxmox']['installer_profile']['runtime']['compose_entrypoint'],
transaction.TAILSCALE_ENTRYPOINT)
def test_other_commands_and_applications_are_left_alone(self):
for template in ({'id': 'image-tailscale', 'proxmox': {'installer_profile': {'runtime': {
'compose_entrypoint': '/bin/sh -c "tailscaled & tailscale web --listen 0.0.0.0:8080"'}}}},
{'id': 'image-other', 'proxmox': {'installer_profile': {'runtime': {
'compose_entrypoint': transaction.TAILSCALE_PREVIOUS_ENTRYPOINT}}}}):
before = copy.deepcopy(template)
transaction.current_recipe(template)
self.assertEqual(template, before)
class CommandTests(unittest.TestCase):
def run_command(self, address):
with tempfile.TemporaryDirectory() as temp:
stubs = Path(temp)
inet = f' inet {address}/24 brd 192.0.2.255 scope global eth0' if address else ''
for name, body in (('sysctl', 'echo "sysctl $*" >> "$TRACE"'), ('tailscaled', 'echo tailscaled >> "$TRACE"'),
('sleep', ':'), ('ip', f"printf '%s\\n' '{inet}'"),
('tailscale', 'echo "tailscale $*" >> "$TRACE"')):
path = stubs / name
path.write_text(f'#!/bin/sh\n{body}\n')
path.chmod(0o755)
trace = stubs / 'trace'
argv = shlex.split(transaction.TAILSCALE_ENTRYPOINT)
subprocess.run(argv, env=dict(os.environ, PATH=f'{stubs}:/usr/bin:/bin', TRACE=str(trace)),
check=True, timeout=30)
return trace.read_text().splitlines()
def test_the_web_interface_listens_on_the_lan_address(self):
trace = self.run_command('192.0.2.5')
self.assertIn('sysctl -w net.ipv4.ip_forward=1 net.ipv6.conf.all.forwarding=1', trace)
self.assertIn('tailscaled', trace)
self.assertEqual(trace[-1], 'tailscale web --listen 192.0.2.5:5252')
def test_without_an_address_it_listens_everywhere_as_before(self):
self.assertEqual(self.run_command('')[-1], 'tailscale web --listen 0.0.0.0:5252')
if __name__ == '__main__':
unittest.main()
@@ -2021,6 +2021,13 @@ hb_ensure_borg() {
# without the new AppImage)
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
# Cached/bundled/downloaded borg-linux64 below is AMD64 only. A native
# distro Borg may be used above, but never try a foreign cached executable.
if [[ "$(uname -m)" != x86_64 ]]; then
msg_error "$(hb_translate "Native Borg is unavailable. Install the native borgbackup package on this host to use Borg backups.")"
return 1
fi
local appimage_cache="$HB_STATE_DIR/borg"
[[ -x "$appimage_cache" ]] && { echo "$appimage_cache"; return 0; }
+189
View File
@@ -0,0 +1,189 @@
"""Keep kernel USB references until a managed, disarmed match is released.
usbfs descriptors hold usb_device references (including its USB parents).
A kernel remove uevent confirms disconnect finished before absent-slot rebind.
A pathname or a sysfs directory descriptor alone is not a USB lifetime pin.
Lost events, unverified binds and ownership recovered without pins fail closed.
"""
import os
from pathlib import Path
import socket
import stat
import time
class LifetimeError(RuntimeError):
pass
class Lease:
def __init__(self, sysfs, busid, expected):
self.path = Path(sysfs) / busid
self.fds = []
self.ready = False
self.removed = False
self.events = socket.socket(
socket.AF_NETLINK, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC, 15
)
try:
self.events.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024 * 1024)
self.events.bind((0, 1))
self.events.setblocking(False)
self.directory = os.open(
self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
)
self.fds.append(self.directory)
self.object = self._object(os.fstat(self.directory))
self.devpath = str(self.path.resolve()).removeprefix("/sys")
self.bus = int(self.read("busnum"))
self.number = int(self.read("devnum"))
self.identity = {
key: self.read(name)
for key, name in [
("vendor", "idVendor"),
("product", "idProduct"),
("serial", "serial"),
]
}
if self.identity != {key: expected[key] for key in self.identity}:
raise LifetimeError("USB changed before its lifetime could be pinned")
# Keep every USB ancestor alive too: do_rebind locks the parent.
current = self.path.resolve()
while (current / "busnum").exists():
bus = int((current / "busnum").read_text())
number = int((current / "devnum").read_text())
fd = os.open(
f"/dev/bus/usb/{bus:03}/{number:03}",
os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW,
)
self.fds.append(fd)
info = os.fstat(fd)
if not stat.S_ISCHR(info.st_mode) or os.major(info.st_rdev) != 189:
raise LifetimeError("USB lifetime reference is not a usbfs device")
if os.minor(info.st_rdev) != (bus - 1) * 128 + number - 1:
raise LifetimeError("USB lifetime reference changed")
current = current.parent
self.validate()
# Ignore older queued events, including a previous incarnation with
# a reused port and USB device number.
self.sequence = int(Path("/sys/kernel/uevent_seqnum").read_text())
except BaseException:
self.close()
raise
@staticmethod
def _object(info):
return info.st_dev, info.st_ino
def read(self, name):
try:
fd = os.open(
name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW, dir_fd=self.directory
)
except FileNotFoundError:
if name == "serial":
return ""
raise
try:
return os.read(fd, 1024).decode().strip()
finally:
os.close(fd)
def validate(self):
if (
self._object(os.stat(self.path)) != self.object
or int(self.read("busnum")) != self.bus
or int(self.read("devnum")) != self.number
or any(
self.read(name) != self.identity[key]
for key, name in [
("vendor", "idVendor"),
("product", "idProduct"),
("serial", "serial"),
]
)
):
raise LifetimeError("Pinned USB was removed or replaced")
def confirm(self, disarm):
# REMOV prevents a replacement from overwriting the table's udev pointer.
# Verify the pinned original is still bound AFTER that transition.
disarm(self.path.name)
self.validate()
if Path(os.readlink("driver", dir_fd=self.directory)).name != "usbip-host":
raise LifetimeError("Pinned USB was not bound by USB/IP")
self.read("usbip_status")
self.ready = True
def poll_removed(self):
while True:
try:
packet, _, flags, sender = self.events.recvmsg(65536)
except BlockingIOError:
return self.removed
except OSError as exc:
self.ready = False
raise LifetimeError(
"USB removal events were lost; restart the host before reusing this slot"
) from exc
if flags & socket.MSG_TRUNC:
self.ready = False
raise LifetimeError("Truncated USB removal event")
if sender[0] != 0:
continue
fields = dict(
value.split(b"=", 1) for value in packet.split(b"\0") if b"=" in value
)
if (
fields.get(b"ACTION") == b"remove"
and fields.get(b"DEVTYPE") == b"usb_device"
and fields.get(b"DEVPATH") == self.devpath.encode()
and fields.get(b"BUSNUM") == f"{self.bus:03}".encode()
and fields.get(b"DEVNUM") == f"{self.number:03}".encode()
and int(fields.get(b"SEQNUM", b"0")) > self.sequence
):
self.removed = True
def release(self, match, unbind):
if not self.ready:
raise LifetimeError(
"USB bind was not verified; restart the host before reusing this slot"
)
self.poll_removed()
if not self.removed:
try:
self.validate()
except (OSError, LifetimeError):
# Enumeration may already have removed sysfs while disconnect is
# still running. Wait for its completed USB-device remove event.
deadline = time.monotonic() + 2
while not self.poll_removed() and time.monotonic() < deadline:
time.sleep(0.02)
if not self.removed:
raise LifetimeError(
"Waiting for completed USB removal; no slot recovery was attempted"
)
else:
unbind(self.path.name)
if self.path.name not in match.read_text().split():
self.close()
return
# Physical disconnect can race the native unbind. Only a remove
# event allows the pinned absent path below.
if not self.poll_removed():
raise LifetimeError("USB/IP unbind did not clear its match")
# Unlike a stale pathname, these still-open usbfs files pin the exact
# udev and parent referenced by this verified REMOV slot. No newer probe
# can overwrite it. Removal notification is emitted after stub_disconnect.
(match.parent / "rebind").write_text(self.path.name)
if self.path.name in match.read_text().split():
raise LifetimeError("Pinned USB/IP match cleanup was not confirmed")
self.close()
def close(self):
for fd in self.fds:
os.close(fd)
self.fds.clear()
self.events.close()
self.ready = False
+944
View File
@@ -0,0 +1,944 @@
#!/usr/bin/env python3
"""Manage selected USB/IP exports and Linux clients over authenticated SSH.
The server unit has a private network namespace. OpenSSH reaches its root-only
filesystem Unix socket through a dedicated forced-command account; usbipd never
listens in the host's network namespace.
Only this module's recorded devices and imported ports are released on stop.
"""
import argparse
from contextlib import contextmanager
import copy
import errno
import fcntl
import ipaddress
import json
import os
import pwd
import socketserver
import threading
from pathlib import Path
import re
import signal
import socket
import stat
import subprocess
import sys
import tempfile
import time
import uuid
CONFIG_DIR = Path("/etc/proxmenux/usbip")
RUN_DIR = Path("/run/proxmenux-usbip")
SERVER_RUN_DIR = Path("/run/proxmenux-usbip-server")
UNIT_DIR = Path("/etc/systemd/system")
SYSFS = Path("/sys/bus/usb/devices")
SCRIPT = Path(__file__).resolve()
REMOTE_SCRIPT = "/usr/local/share/proxmenux/scripts/devices/usbip_manager.py"
REMOTE_SOCKET = "/run/proxmenux-usbip-server/usbip.sock"
SERVER_UNIT = "proxmenux-usbip-server.service"
MARKER = "# Managed by ProxMenux USB/IP"
TRANSPORT_USER = "proxmenux-usbip"
TRANSPORT_HOME = Path("/var/lib/proxmenux-usbip")
MATCH_BUSID = Path("/sys/bus/usb/drivers/usbip-host/match_busid")
NET_SYSFS = Path("/sys/class/net")
BUSID = re.compile(r"[0-9]+-[0-9]+(?:\.[0-9]+)*\Z")
IDENTIFIER = re.compile(r"[a-f0-9]{12}\Z")
class USBIPError(Exception):
pass
def run(args, timeout=20):
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout,
env={**os.environ, "LC_ALL": "C"})
except (OSError, subprocess.TimeoutExpired) as exc:
raise USBIPError(f"Could not run {args[0]}: {type(exc).__name__}") from exc
if result.returncode:
detail = (result.stderr or result.stdout).strip()[:500]
raise USBIPError(f"{Path(args[0]).name} failed: {detail}")
return result.stdout
def atomic_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, name = tempfile.mkstemp(prefix=".usbip-", dir=path.parent)
try:
with os.fdopen(fd, "w") as handle:
os.fchmod(handle.fileno(), 0o600)
json.dump(value, handle, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def read_json(path, default):
try:
if path.stat().st_size > 1024 * 1024:
raise USBIPError(f"File is too large: {path}")
return json.loads(path.read_text())
except FileNotFoundError:
return copy.deepcopy(default)
except (ValueError, OSError) as exc:
raise USBIPError(f"Cannot read {path}") from exc
def load_config():
value = read_json(CONFIG_DIR / "config.json", {"server": False, "exports": [], "imports": []})
if not isinstance(value, dict) or not isinstance(value.get("server"), bool):
raise USBIPError("Invalid USB/IP configuration")
for field in ("exports", "imports"):
if not isinstance(value.get(field), list) or len(value[field]) > 64:
raise USBIPError("Invalid USB/IP configuration")
for item in value[field]:
if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not IDENTIFIER.fullmatch(item["id"]):
raise USBIPError("Invalid USB/IP device identifier")
return value
@contextmanager
def config_lock():
CONFIG_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
with (CONFIG_DIR / ".lock").open("a") as lock:
os.fchmod(lock.fileno(), 0o600)
fcntl.flock(lock, fcntl.LOCK_EX)
yield
def attribute(path, name):
try:
return (path / name).read_text().strip()[:200]
except (FileNotFoundError, OSError):
return ""
def network_interfaces(path):
"""Kernel network interfaces provided by this USB device. On many mini PCs
a USB adapter is the management link: sharing it would cut the host off."""
device = path.resolve()
names = []
for interface in NET_SYSFS.glob("*"):
try:
source = (interface / "device").resolve(strict=True)
except OSError:
continue
if source == device or device in source.parents:
names.append(interface.name)
return sorted(names)
def inventory():
devices = []
for path in sorted(SYSFS.glob("*")):
if not BUSID.fullmatch(path.name):
continue
# Imported devices cannot be re-exported by usbip-host. Exclude them
# before identity matching so a local client cannot shadow its source.
if any(parent.name.startswith("vhci_hcd") for parent in path.resolve().parents):
continue
vendor, product = attribute(path, "idVendor"), attribute(path, "idProduct")
if not re.fullmatch(r"[a-fA-F0-9]{4}", vendor) or not re.fullmatch(r"[a-fA-F0-9]{4}", product):
continue
classes = {attribute(path, "bDeviceClass").lower()}
classes.update(attribute(p, "bInterfaceClass").lower() for p in path.glob(f"{path.name}:*"))
# Interfaces are also symlinked directly below /sys/bus/usb/devices.
classes.update(attribute(p, "bInterfaceClass").lower() for p in SYSFS.glob(f"{path.name}:*"))
blocked = "USB hubs cannot be shared" if "09" in classes else (
"USB mass storage cannot be shared" if "08" in classes else (
"USB class could not be read" if not re.fullmatch(r"[a-f0-9]{2}", attribute(path, "bDeviceClass").lower()) else ""))
if not blocked and network_interfaces(path):
blocked = "USB network adapters cannot be shared"
try:
driver = (path / "driver").resolve(strict=True).name
except OSError:
driver = ""
devices.append({"busid": path.name, "vendor": vendor.lower(), "product": product.lower(),
"serial": attribute(path, "serial"), "name": attribute(path, "product") or f"{vendor}:{product}",
"driver": driver, "blocked": blocked})
return devices
def same_device(expected, device):
if any(expected.get(k) != device.get(k) for k in ("vendor", "product", "serial")):
return False
return bool(expected.get("serial")) or expected.get("busid") == device.get("busid")
def resolve_device(expected, devices):
matches = [d for d in devices if same_device(expected, d)]
if len(matches) != 1:
raise USBIPError("Device is missing or its identity is ambiguous")
if matches[0]["blocked"]:
raise USBIPError(matches[0]["blocked"])
return matches[0]
def state_path():
return SERVER_RUN_DIR / "exports.json"
def disarm_match(busid):
try:
MATCH_BUSID.write_text(f"del {busid}")
except FileNotFoundError:
pass
except OSError as exc:
if exc.errno != errno.ENODEV:
raise USBIPError("Could not disarm the USB/IP match") from exc
def release_export(identifier, owned, devices, leases=None):
previous = owned[identifier]
current = next((d for d in devices if d["busid"] == previous["busid"]), None)
if not BUSID.fullmatch(previous.get("busid", "")):
raise USBIPError("Invalid owned USB bus ID")
if leases is not None:
# A restart may have lost the old pins. Recover only a currently present,
# identity-matching, bound object; never pin a replacement by pathname.
if (current and same_device(previous, current) and current["driver"] == "usbip-host"
and (identifier not in leases or not leases[identifier].ready)):
from usbip_lifetime import Lease
recovered = Lease(SYSFS, current["busid"], current)
try:
recovered.confirm(disarm_match)
except BaseException:
recovered.close()
raise
old = leases.pop(identifier, None)
if old:
old.close()
leases[identifier] = recovered
if identifier in leases:
leases[identifier].release(MATCH_BUSID, lambda busid: run(["usbip", "unbind", "--busid", busid]))
del leases[identifier]
del owned[identifier]
atomic_json(state_path(), owned)
return
if current and same_device(previous, current) and current["driver"] == "usbip-host":
run(["usbip", "unbind", "--busid", current["busid"]])
else:
# A successful 'del' write does not prove removal on every kernel:
# upstream retains ADDED/REMOV table slots after physical unplug.
# Never forget ownership or bind a new port while that slot survives.
disarm_match(previous["busid"])
try:
remaining = MATCH_BUSID.read_text().split()
except FileNotFoundError:
remaining = []
if previous["busid"] in remaining:
raise USBIPError("Kernel retained the unplugged USB/IP match; automatic reconnect is blocked pending safe cleanup")
del owned[identifier]
atomic_json(state_path(), owned)
def reconcile_exports(config, owned, leases=None):
devices = inventory()
desired = {item["id"]: item for item in config["exports"]} if config["server"] else {}
errors = {}
for identifier in list(owned):
previous = owned[identifier]
current = next((d for d in devices if d["busid"] == previous["busid"]), None)
try:
removed = leases is not None and identifier in leases and leases[identifier].poll_removed()
if identifier not in desired or current is None or not same_device(previous, current) or removed:
release_export(identifier, owned, devices, leases)
except (USBIPError, OSError, RuntimeError) as exc:
errors[identifier] = str(exc)
for identifier, selected in desired.items():
if identifier in errors:
continue
try:
device = resolve_device(selected, devices)
previous = owned.get(identifier)
if previous and (previous["busid"] != device["busid"] or not same_device(previous, device)):
release_export(identifier, owned, devices, leases)
previous = None
if device["driver"] == "usbip-host":
if not previous:
raise USBIPError("Device is already exported outside ProxMenux; it was not adopted")
if leases is not None and (identifier not in leases or not leases[identifier].ready):
raise USBIPError("Recovered export has no lifetime pin; stop sharing while the original USB remains connected, or restart the host")
disarm_match(device["busid"])
continue
if device["busid"] in MATCH_BUSID.read_text().split():
raise USBIPError("Kernel retained this USB/IP slot; cleanup must finish before binding so the local driver is preserved")
if leases is not None:
from usbip_lifetime import Lease
if identifier in leases:
leases.pop(identifier).close()
leases[identifier] = Lease(SYSFS, device["busid"], device)
# Record intent before binding, so a crash cannot orphan our export.
owned[identifier] = {k: device[k] for k in ("busid", "vendor", "product", "serial")}
atomic_json(state_path(), owned)
run(["usbip", "bind", "--busid", device["busid"]])
# ALLOC -> REMOV rejects future probes without interrupting this
# live export. Do not leave a reusable automatic port match armed.
if leases is None:
disarm_match(device["busid"])
else:
leases[identifier].confirm(disarm_match)
except (USBIPError, OSError, ValueError, RuntimeError) as exc:
errors[identifier] = str(exc)
atomic_json(SERVER_RUN_DIR / "status.json", {"errors": errors, "updated": time.time()})
return errors
def catalog():
config, devices, owned = load_config(), inventory(), read_json(state_path(), {})
errors = read_json(SERVER_RUN_DIR / "status.json", {}).get("errors", {})
exports = []
for selected in config["exports"]:
row = dict(selected, status="waiting", busid=None)
if selected["id"] in errors:
exports.append(dict(row, error=errors[selected["id"]]))
continue
try:
current = resolve_device(selected, devices)
if config["server"] and selected["id"] in owned and current["driver"] == "usbip-host" and (
owned[selected["id"]]["busid"] == current["busid"] and same_device(owned[selected["id"]], current)
):
row.update(busid=current["busid"], status="exported")
except USBIPError as exc:
row["error"] = str(exc)
exports.append(row)
return exports
def systemd_quote(value):
if any(ord(c) < 32 for c in str(value)):
raise USBIPError("Control characters are not allowed in systemd arguments")
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"').replace("%", "%%").replace("$", "$$") + '"'
def unit_text(role, identifier=None):
command = f"{systemd_quote(sys.executable)} {systemd_quote(SCRIPT)}"
if role == "server":
extra = "PrivateNetwork=yes\nRuntimeDirectory=proxmenux-usbip-server\nRuntimeDirectoryMode=0750\nRuntimeDirectoryPreserve=yes\n"
start = f"{command} serve"
module = "usbip-host"
else:
if not IDENTIFIER.fullmatch(identifier or ""):
raise USBIPError("Invalid import identifier")
start = f"{command} connect {identifier}"
module = "vhci-hcd"
import shutil
modprobe = shutil.which("modprobe")
if not modprobe:
raise USBIPError("modprobe is not installed")
return (f"{MARKER}\n[Unit]\nDescription=ProxMenux USB/IP {role} over SSH\n"
+ ("Wants=network-online.target\nAfter=network-online.target\n" if role == "client" else "")
+ "StartLimitIntervalSec=0\n\n[Service]\nType=simple\nUser=root\nUMask=0077\n"
+ f"ExecStartPre={systemd_quote(modprobe)} {module}\nExecStart={start}\n"
+ "Restart=on-failure\nRestartSec=5\nTimeoutStopSec=30\nPrivateTmp=yes\nNoNewPrivileges=yes\n"
+ (extra if role == "server" else "")
+ "\n[Install]\nWantedBy=multi-user.target\n")
def unit_name(identifier):
if not IDENTIFIER.fullmatch(identifier):
raise USBIPError("Invalid import identifier")
return f"proxmenux-usbip-client-{identifier}.service"
def check_unit_owned(name):
path = UNIT_DIR / name
if path.is_symlink() or (path.exists() and not path.read_text().startswith(MARKER + "\n")):
raise USBIPError(f"Refusing to replace a unit not owned by ProxMenux: {name}")
return path
def write_unit(name, content):
path = check_unit_owned(name)
path.write_text(content)
path.chmod(0o644)
def enable_unit(name, content):
path = check_unit_owned(name)
original = path.read_text() if path.exists() else None
try:
write_unit(name, content)
run(["systemctl", "daemon-reload"])
run(["systemctl", "enable", "--now", name], timeout=40)
run(["systemctl", "is-active", "--quiet", name])
except USBIPError:
if original is None:
try:
run(["systemctl", "disable", "--now", name], timeout=40)
except USBIPError:
pass
path.unlink(missing_ok=True)
else:
path.write_text(original)
run(["systemctl", "daemon-reload"])
raise
def remove_unit(name):
path = check_unit_owned(name)
if path.exists():
run(["systemctl", "disable", "--now", name], timeout=40)
path.unlink()
run(["systemctl", "daemon-reload"])
def validate_remote(host, port, key):
try:
ipaddress.ip_address(host)
except ValueError:
if len(host) > 253 or not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host):
raise USBIPError("Enter an IP address or DNS hostname without a username or SSH options")
if not 1 <= int(port) <= 65535:
raise USBIPError("Invalid SSH port")
key_path = Path(key)
if not key_path.is_absolute() or any(ord(c) < 32 for c in key):
raise USBIPError("SSH key must be an absolute path")
try:
mode = key_path.stat()
if not stat.S_ISREG(mode.st_mode) or mode.st_uid != os.geteuid() or mode.st_mode & 0o077:
raise USBIPError("SSH key must belong to this user and have permissions 0600 or 0400")
except OSError as exc:
raise USBIPError("SSH private key is not readable") from exc
return {"host": host, "ssh_port": int(port), "key": str(key_path)}
def ssh_args(remote):
return ["ssh", "-F", "/dev/null", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no",
"-o", "IdentitiesOnly=yes", "-o", "ForwardAgent=no", "-o", "ControlMaster=no",
"-o", "ControlPath=none", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3", "-o", "ExitOnForwardFailure=yes",
"-p", str(remote["ssh_port"]), "-i", remote["key"], "-l", TRANSPORT_USER]
def remote_catalog(remote):
# The remote command is fixed. All user input stays in separate local argv.
output = run(ssh_args(remote) + ["--", remote["host"], "catalog"])
try:
result = json.loads(output)
if not isinstance(result, list) or len(result) > 64:
raise ValueError()
for item in result:
if not isinstance(item, dict) or not isinstance(item.get("id"), str) or not IDENTIFIER.fullmatch(item["id"]):
raise ValueError()
if item.get("busid") is not None and (not isinstance(item["busid"], str) or not BUSID.fullmatch(item["busid"])):
raise ValueError()
return result
except (ValueError, TypeError) as exc:
raise USBIPError("Remote host did not return a valid ProxMenux USB/IP catalog") from exc
def tunnel_args(selected):
# No forwarding channels or shell commands: the server key forces the
# unprivileged transport gate, which accepts only catalog or stream.
return ssh_args(selected) + ["--", selected["host"], "stream"]
class LoopbackTunnel:
"""Keep the TCP port reserved from bind until cleanup (no probe/bind race)."""
def __init__(self, selected):
self.processes = set()
self.lock = threading.Lock()
tunnel = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
process = None
try:
with tunnel.lock:
if tunnel.closed:
return
process = subprocess.Popen(tunnel_args(selected),
stdin=self.request, stdout=self.request)
tunnel.processes.add(process)
process.wait()
finally:
with tunnel.lock:
if process:
tunnel.processes.discard(process)
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
# A restarted worker binds again while earlier connections are in
# TIME_WAIT. Linux still refuses a port another socket listens on.
allow_reuse_address = True
self.closed = False
try:
self.server = Server(("127.0.0.1", selected["local_port"]), Handler)
except OSError as exc:
raise USBIPError("Local tunnel port is already in use") from exc
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def poll(self):
return 0 if self.closed or not self.thread.is_alive() else None
def terminate(self):
with self.lock:
self.closed = True
processes = list(self.processes)
self.server.shutdown()
self.server.server_close()
for process in processes:
stop_child(process)
self.thread.join(timeout=5)
def wait(self, timeout=5):
self.thread.join(timeout)
return 0
def transport_account():
try:
account = pwd.getpwnam(TRANSPORT_USER)
except KeyError as exc:
raise USBIPError("Authorize a dedicated USB/IP client key on the server first") from exc
if account.pw_uid == 0 or account.pw_gid == 0 or account.pw_dir != str(TRANSPORT_HOME):
raise USBIPError("Unsafe USB/IP transport account")
return account
def authorize_key(path):
# Root-owned home and forced-command keys: this account cannot replace its
# own policy, log in with a cluster root key, or open SSH forwarding channels.
key = Path(path).read_text().strip()
fields = key.split()
if len(fields) < 2 or fields[0] not in ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256"):
raise USBIPError("Provide an SSH public key without authorized_keys options")
import base64
try:
base64.b64decode(fields[1], validate=True)
except ValueError as exc:
raise USBIPError("Invalid SSH public key") from exc
created = False
try:
pwd.getpwnam(TRANSPORT_USER)
except KeyError:
run(["useradd", "--system", "--user-group", "--home-dir", str(TRANSPORT_HOME),
"--shell", "/bin/sh", "--password", "*", TRANSPORT_USER])
created = True
account = transport_account()
# Never adopt a pre-existing home or policy owned by another administrator.
marker = TRANSPORT_HOME / ".proxmenux-owned"
if not created and not marker.is_file():
raise USBIPError("Refusing to adopt an existing USB/IP transport home")
TRANSPORT_HOME.mkdir(mode=0o750, parents=True, exist_ok=True)
marker.touch(mode=0o600)
ssh_dir = TRANSPORT_HOME / ".ssh"
ssh_dir.mkdir(mode=0o750, exist_ok=True)
policy = ssh_dir / "authorized_keys"
line = f'restrict,command="/usr/bin/python3 {REMOTE_SCRIPT} transport" {fields[0]} {fields[1]}\n'
for directory in (TRANSPORT_HOME, ssh_dir):
os.chown(directory, 0, account.pw_gid)
directory.chmod(0o750)
existing = policy.read_text() if policy.exists() else ""
if line not in existing.splitlines(keepends=True):
fd = os.open(policy, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o640)
with os.fdopen(fd, "a") as handle:
os.fchown(handle.fileno(), 0, account.pw_gid)
os.fchmod(handle.fileno(), 0o640)
handle.write(line)
def transport():
account = transport_account()
if os.geteuid() != account.pw_uid:
raise USBIPError("Transport requires the dedicated unprivileged account")
command = os.environ.get("SSH_ORIGINAL_COMMAND", "")
if command == "catalog":
print((SERVER_RUN_DIR / "catalog.json").read_text())
elif command == "stream":
os.execvp("socat", ["socat", "STDIO", f"UNIX-CONNECT:{REMOTE_SOCKET}"])
else:
raise USBIPError("Only USB/IP catalog and stream requests are permitted")
def publish_catalog():
path = SERVER_RUN_DIR / "catalog.json"
atomic_json(path, catalog())
os.chown(path, 0, transport_account().pw_gid)
path.chmod(0o640)
def parse_ports(output):
ports, current = [], None
for line in output.splitlines():
match = re.match(r"Port (\d+): <([^>]+)>", line)
if match:
current = {"port": int(match[1]), "status": match[2]}
continue
match = re.match(r"\s*(\S+) -> usbip://([^\s/]+):([0-9]+)/([0-9.-]+)\s*$", line)
if match and current is not None:
ports.append(dict(current, local_busid=match[1], host=match[2], tcp_port=int(match[3]), busid=match[4]))
current = None
return ports
def imported_ports():
return parse_ports(run(["usbip", "port"]))
def owns_import(saved, current):
return all(saved.get(k) == current.get(k) for k in ("port", "local_busid", "host", "tcp_port", "busid"))
def release_import(path):
saved = read_json(path, None)
if saved:
for current in imported_ports():
if owns_import(saved, current):
run(["usbip", "detach", "--port", str(current["port"])])
path.unlink(missing_ok=True)
def find_import(selected, busid):
return next((p for p in imported_ports() if p["host"] == "127.0.0.1" and
p["tcp_port"] == selected["local_port"] and p["busid"] == busid), None)
def attach_once(selected, owner):
saved = read_json(owner, None)
if saved:
existing = next((p for p in imported_ports() if owns_import(saved, p)), None)
if existing and existing["status"] == "Port in Use":
return existing
# A kernel port in an error state must be released before retrying.
release_import(owner)
remote = next((e for e in remote_catalog(selected) if e["id"] == selected["export_id"]), None)
if not remote or remote.get("status") != "exported" or not remote.get("busid"):
raise USBIPError("Remote device is unplugged, disabled or waiting to be exported")
if find_import(selected, remote["busid"]):
raise USBIPError("This endpoint has an unowned import; it was not adopted or detached")
run(["usbip", "--tcp-port", str(selected["local_port"]), "attach", "--remote", "127.0.0.1",
"--busid", remote["busid"]])
# The kernel publishes the port record shortly after attach returns.
attached = None
for _ in range(20):
attached = find_import(selected, remote["busid"])
if attached:
break
time.sleep(0.25)
if not attached:
raise USBIPError("USB/IP did not report an attached port")
atomic_json(owner, attached)
return attached
def require_private_network():
if os.readlink("/proc/self/ns/net") == os.readlink("/proc/1/ns/net"):
raise USBIPError("The USB/IP server must run in its systemd PrivateNetwork namespace")
def stop_child(process):
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@contextmanager
def stop_event():
import threading
stopped = threading.Event()
old = {sig: signal.signal(sig, lambda *_: stopped.set()) for sig in (signal.SIGTERM, signal.SIGINT)}
try:
yield stopped
finally:
for sig, handler in old.items():
signal.signal(sig, handler)
def serve():
require_private_network()
SERVER_RUN_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
account = transport_account()
os.chown(SERVER_RUN_DIR, 0, account.pw_gid)
SERVER_RUN_DIR.chmod(0o750)
socket_path = SERVER_RUN_DIR / "usbip.sock"
socket_path.unlink(missing_ok=True)
owned = read_json(state_path(), {})
leases = {}
from usbip_protocol import Gate
def claim(busid):
# Re-read selection for every import, including deselection between polls.
config = load_config()
if not config["server"]:
return None
for selected in config["exports"]:
identifier = selected["id"]
lease = leases.get(identifier)
previous = owned.get(identifier)
if (lease and lease.ready and previous and previous["busid"] == busid
and same_device(selected, previous)):
return dict(previous, id=identifier)
return None
daemon = proxy = None
try:
daemon = subprocess.Popen(["usbipd", "--ipv4", "--tcp-port", "3240"])
proxy = Gate(socket_path, account.pw_gid, claim, SYSFS)
with stop_event() as stopped:
previous_errors = None
while not stopped.is_set():
if daemon.poll() is not None or proxy.poll() is not None:
raise USBIPError("USB/IP daemon or Unix proxy exited")
config = load_config()
if not config["server"]:
break
errors = reconcile_exports(config, owned, leases)
publish_catalog()
if errors != previous_errors:
for identifier, error in errors.items():
print(f"Export {identifier}: {error}", file=sys.stderr, flush=True)
previous_errors = errors
stopped.wait(3)
finally:
stop_child(proxy)
devices = inventory()
for identifier in list(owned):
try:
release_export(identifier, owned, devices, leases)
except (USBIPError, OSError, RuntimeError) as exc:
print(f"Could not release export {identifier}: {exc}", file=sys.stderr, flush=True)
for lease in leases.values():
lease.close()
stop_child(daemon)
socket_path.unlink(missing_ok=True)
(SERVER_RUN_DIR / "catalog.json").unlink(missing_ok=True)
def connect(identifier):
selected = next((i for i in load_config()["imports"] if i["id"] == identifier), None)
if selected is None:
raise USBIPError("Import not found")
validate_remote(selected["host"], selected["ssh_port"], selected["key"])
RUN_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
owner = RUN_DIR / f"client-{identifier}.json"
status_path = RUN_DIR / f"client-{identifier}-status.json"
tunnel, last_error = None, None
try:
# Recover only this client's exact previous port after an interrupted run.
release_import(owner)
with stop_event() as stopped:
while not stopped.is_set():
try:
if tunnel is None or tunnel.poll() is not None:
release_import(owner)
stop_child(tunnel)
tunnel = LoopbackTunnel(selected)
if stopped.wait(1):
break
if tunnel.poll() is not None:
raise USBIPError("SSH tunnel failed; check key, host key and forwarding permissions")
attached = attach_once(selected, owner)
atomic_json(status_path, {"state": "attached", "port": attached["port"], "updated": time.time()})
last_error = None
except USBIPError as exc:
atomic_json(status_path, {"state": "waiting", "error": str(exc), "updated": time.time()})
if str(exc) != last_error:
print(str(exc), file=sys.stderr, flush=True)
last_error = str(exc)
stopped.wait(5)
finally:
try:
release_import(owner)
finally:
stop_child(tunnel)
atomic_json(status_path, {"state": "stopped", "updated": time.time()})
def update_server(enabled):
with config_lock():
config = load_config()
previous = copy.deepcopy(config)
check_unit_owned(SERVER_UNIT)
config["server"] = enabled
atomic_json(CONFIG_DIR / "config.json", config)
try:
if enabled:
transport_account()
enable_unit(SERVER_UNIT, unit_text("server"))
else:
remove_unit(SERVER_UNIT)
# Retry cleanup even if the worker had previously crashed.
owned = read_json(state_path(), {})
errors = reconcile_exports(config, owned, {})
if errors:
raise USBIPError("Server stopped, but some device bindings could not be released; check status and retry disable")
except USBIPError:
if enabled:
atomic_json(CONFIG_DIR / "config.json", previous)
raise
def add_export(busid):
if not BUSID.fullmatch(busid):
raise USBIPError("Invalid USB bus ID")
with config_lock():
config = load_config()
if len(config["exports"]) >= 64:
raise USBIPError("At most 64 exports can be configured")
if not config["server"]:
raise USBIPError("Enable the USB/IP server first")
device = next((d for d in inventory() if d["busid"] == busid), None)
if not device:
raise USBIPError("Device not found")
if device["blocked"]:
raise USBIPError(device["blocked"])
if device["driver"] == "usbip-host":
raise USBIPError("Device is already exported; it was not adopted")
if any(same_device(e, device) for e in config["exports"]):
raise USBIPError("Device is already configured")
resolve_device(device, inventory())
selected = {k: device[k] for k in ("busid", "vendor", "product", "serial", "name")}
selected["id"] = uuid.uuid4().hex[:12]
config["exports"].append(selected)
atomic_json(CONFIG_DIR / "config.json", config)
return selected
def remove_export(identifier):
with config_lock():
config = load_config()
if not any(e["id"] == identifier for e in config["exports"]):
raise USBIPError("Export not found")
config["exports"] = [e for e in config["exports"] if e["id"] != identifier]
atomic_json(CONFIG_DIR / "config.json", config)
requested = time.time()
deadline = time.monotonic() + 10
while identifier in read_json(state_path(), {}):
status = read_json(SERVER_RUN_DIR / "status.json", {})
if status.get("updated", 0) >= requested and identifier in status.get("errors", {}):
raise USBIPError("Selection removed, but release is incomplete: " + status["errors"][identifier])
if time.monotonic() >= deadline:
raise USBIPError("Selection removed; USB release has not been confirmed. Check server status")
time.sleep(.1)
def add_import(remote, export_id, local_port):
if not IDENTIFIER.fullmatch(export_id):
raise USBIPError("Invalid remote export ID")
if not 1024 <= local_port <= 65535:
raise USBIPError("Use a local TCP port between 1024 and 65535")
exports = remote_catalog(remote) # Authenticate and validate before saving anything.
if not any(e["id"] == export_id and e.get("status") == "exported" for e in exports):
raise USBIPError("Remote export is not available; enable the server and select a device first")
with config_lock():
config = load_config()
if len(config["imports"]) >= 64:
raise USBIPError("At most 64 imports can be configured")
if any(i["local_port"] == local_port or (i["host"] == remote["host"] and
i["ssh_port"] == remote["ssh_port"] and i["export_id"] == export_id) for i in config["imports"]):
raise USBIPError("This remote device or local port is already configured")
with socket.socket() as check:
check.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
check.bind(("127.0.0.1", local_port))
except OSError as exc:
raise USBIPError("Local tunnel port is already in use") from exc
previous = copy.deepcopy(config)
selected = dict(remote, export_id=export_id, local_port=local_port, id=uuid.uuid4().hex[:12])
config["imports"].append(selected)
atomic_json(CONFIG_DIR / "config.json", config)
try:
enable_unit(unit_name(selected["id"]), unit_text("client", selected["id"]))
except USBIPError:
atomic_json(CONFIG_DIR / "config.json", previous)
raise
return selected
def remove_import(identifier):
with config_lock():
config = load_config()
if not any(i["id"] == identifier for i in config["imports"]):
raise USBIPError("Import not found")
remove_unit(unit_name(identifier))
# Also recover a port left by a process killed without its finally block.
release_import(RUN_DIR / f"client-{identifier}.json")
config["imports"] = [i for i in config["imports"] if i["id"] != identifier]
atomic_json(CONFIG_DIR / "config.json", config)
(RUN_DIR / f"client-{identifier}-status.json").unlink(missing_ok=True)
def status():
config = load_config()
result = {"server": config["server"], "exports": catalog(), "imports": []}
def active(unit):
try:
run(["systemctl", "is-active", "--quiet", unit])
return True
except USBIPError:
return False
result["server_active"] = active(SERVER_UNIT)
result["server_status"] = read_json(SERVER_RUN_DIR / "status.json", {})
for item in config["imports"]:
result["imports"].append(dict(item, active=active(unit_name(item["id"])),
status=read_json(RUN_DIR / f"client-{item['id']}-status.json", {"state": "unknown"})))
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
for name in ("inventory", "catalog", "status", "server-enable", "server-disable", "serve", "transport"):
commands.add_parser(name)
for name in ("add-export", "remove-export", "remove-import", "connect"):
commands.add_parser(name).add_argument("identifier")
for name in ("remote", "add-import"):
cmd = commands.add_parser(name)
cmd.add_argument("--host", required=True)
cmd.add_argument("--ssh-port", type=int, default=22)
cmd.add_argument("--key", required=True)
if name == "add-import":
cmd.add_argument("--export-id", required=True)
cmd.add_argument("--local-port", type=int, default=33240)
commands.add_parser("authorize-key").add_argument("path")
args = parser.parse_args()
try:
if args.command == "transport":
transport()
return 0
if os.geteuid() != 0:
raise USBIPError("Run this manager as root")
output = None
if args.command == "authorize-key":
authorize_key(args.path)
elif args.command == "inventory":
output = inventory()
elif args.command == "catalog":
output = catalog()
elif args.command == "status":
output = status()
elif args.command in ("server-enable", "server-disable"):
update_server(args.command == "server-enable")
elif args.command == "add-export":
output = add_export(args.identifier)
elif args.command == "remove-export":
remove_export(args.identifier)
elif args.command == "remove-import":
remove_import(args.identifier)
elif args.command == "serve":
serve()
elif args.command == "connect":
connect(args.identifier)
elif args.command in ("remote", "add-import"):
remote = validate_remote(args.host, args.ssh_port, args.key)
output = remote_catalog(remote) if args.command == "remote" else add_import(remote, args.export_id, args.local_port)
if output is not None:
print(json.dumps(output, indent=2))
except (USBIPError, OSError) as exc:
print(f"USB/IP: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+187
View File
@@ -0,0 +1,187 @@
#!/bin/bash
# ProxMenux - selected USB devices over authenticated SSH (GPL-3.0)
set -o pipefail
umask 077
BASE_DIR="/usr/local/share/proxmenux"
LOCAL_SCRIPTS="$BASE_DIR/scripts"
MANAGER="$LOCAL_SCRIPTS/devices/usbip_manager.py"
if [[ -f "$BASE_DIR/utils.sh" ]]; then
source "$BASE_DIR/utils.sh"
load_language
initialize_cache
else
translate() { printf '%s' "$1"; }
fi
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
fi
if [[ $EUID -ne 0 ]]; then
printf '%s\n' "$(translate 'Run this menu as root.')" >&2
exit 1
fi
usbip_dialog() {
dialog --backtitle "ProxMenux" --title "$(translate 'USB/IP over SSH')" "$@"
}
usbip_action() {
local output result=0
output=$(mktemp) || return 1
if python3 "$MANAGER" "$@" >"$output" 2>&1; then
if declare -F pmx_journal_context >/dev/null; then
pmx_journal_context "usbip_${1//-/_}" "1.0" "usbip_menu.sh"
pmx_record_execution "USB/IP: $1" "python3 $MANAGER $1"
fi
usbip_dialog --msgbox "$(translate 'USB/IP configuration updated. Open status to check the connection.')" 9 72
else
usbip_dialog --textbox "$output" 20 85
result=1
fi
rm -f "$output"
return "$result"
}
usbip_dependencies() {
local role="$1" package output
local -a packages=(usbip openssh-client kmod)
[[ "$role" == server ]] && packages+=(socat)
local missing=false
command -v usbip >/dev/null || missing=true
command -v ssh >/dev/null || missing=true
command -v modprobe >/dev/null || missing=true
if [[ "$role" == server ]]; then
command -v usbipd >/dev/null || missing=true
command -v socat >/dev/null || missing=true
fi
[[ "$missing" == false ]] && return 0
usbip_dialog --yesno "$(translate 'Install the USB/IP dependencies from the configured Debian repositories?')" 9 72 || return 1
output=$(mktemp) || return 1
# Keep the operator's repository configuration; do not enable new sources.
if ! apt-get update -o APT::Update::Error-Mode=any >"$output" 2>&1; then
usbip_dialog --textbox "$output" 20 85
rm -f "$output"
return 1
fi
for package in "${packages[@]}"; do
if declare -F pmx_install_pkg >/dev/null; then
pmx_install_pkg "$package" >>"$output" 2>&1
else
DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" >>"$output" 2>&1
fi
if [[ $? -ne 0 ]]; then
usbip_dialog --textbox "$output" 20 85
rm -f "$output"
return 1
fi
done
rm -f "$output"
}
usbip_status() {
local output raw
output=$(mktemp) || return 1
raw=$(mktemp) || { rm -f "$output"; return 1; }
if python3 "$MANAGER" status >"$raw" 2>"$output"; then
jq -r '"SERVER configured: \(.server) running: \(.server_active)\n",
(.exports[] | "EXPORT \(.id) \(.name)\n \(.vendor):\(.product) serial: \(.serial)\n bus: \(.busid // "waiting") state: \(.status)\n"),
(.server_status.errors // {} | to_entries[] | "ERROR \(.key): \(.value)"),
"\nCLIENTS:",
(.imports[] | "\(.id) proxmenux-usbip@\(.host):\(.ssh_port)\n export: \(.export_id) local tunnel: 127.0.0.1:\(.local_port)\n running: \(.active) state: \(.status.state) \(.status.error // "")\n")' "$raw" >"$output"
fi
usbip_dialog --textbox "$output" 26 100
rm -f "$output" "$raw"
}
usbip_add_export() {
local busid label chosen
local -a choices=()
while IFS=$'\t' read -r busid label; do
[[ -n "$busid" ]] && choices+=("$busid" "$label")
done < <(python3 "$MANAGER" inventory | jq -r '.[] | select(.blocked == "" and .driver != "usbip-host") | [.busid, "\(.name) [\(.vendor):\(.product)] serial: \(.serial)"] | @tsv')
if [[ ${#choices[@]} -eq 0 ]]; then
usbip_dialog --msgbox "$(translate 'No eligible USB devices found. Hubs, mass storage and network adapters are excluded.')" 10 75
return
fi
chosen=$(usbip_dialog --stdout --menu "$(translate 'Select the USB device to share:')" 22 100 12 "${choices[@]}") || return
usbip_dialog --yesno "$(translate 'The selected USB device will become unavailable to local applications and guests. Only one remote client can use it at a time. Share this device?')\n\n$(translate 'If the USB/IP service stops unexpectedly, stop sharing while the device is still connected to release it. In rare cases only a host reboot releases it.')\n\n$chosen" 14 80 || return
usbip_action add-export "$chosen"
}
usbip_select_saved() {
local field="$1" identifier label
local -a choices=()
while IFS=$'\t' read -r identifier label; do
[[ -n "$identifier" ]] && choices+=("$identifier" "$label")
done < <(python3 "$MANAGER" status | jq -r --arg field "$field" '.[$field][] | [.id, (.name // .host)] | @tsv')
[[ ${#choices[@]} -gt 0 ]] || return 1
usbip_dialog --stdout --menu "$(translate 'Select a saved USB/IP device:')" 20 90 10 "${choices[@]}"
}
usbip_add_import() {
usbip_dependencies client || return
local host ssh_port key local_port output error selected identifier label
local -a choices=()
usbip_dialog --msgbox "$(translate 'Authorize a dedicated client public key on the server first. The restricted USB/IP account cannot run a shell or forward other sockets. Verify the server host key before connecting; unknown keys are rejected.')" 12 85
host=$(usbip_dialog --stdout --inputbox "$(translate 'USB/IP server IP address or DNS hostname:')" 9 75) || return
ssh_port=$(usbip_dialog --stdout --inputbox "$(translate 'Server SSH port:')" 9 70 "22") || return
key=$(usbip_dialog --stdout --inputbox "$(translate 'Absolute path of the client SSH private key:')" 9 80 "/root/.ssh/id_usbip") || return
output=$(mktemp) || return
error=$(mktemp) || { rm -f "$output"; return; }
if ! python3 "$MANAGER" remote --host "$host" --ssh-port "$ssh_port" --key "$key" >"$output" 2>"$error"; then
usbip_dialog --textbox "$error" 20 90
rm -f "$output" "$error"
return
fi
while IFS=$'\t' read -r identifier label; do
[[ -n "$identifier" ]] && choices+=("$identifier" "$label")
done < <(jq -r '.[] | select(.status == "exported") | [.id, "\(.name) [\(.busid)]"] | @tsv' "$output")
rm -f "$output" "$error"
if [[ ${#choices[@]} -eq 0 ]]; then
usbip_dialog --msgbox "$(translate 'No shared devices are available on this server.')" 9 72
return
fi
selected=$(usbip_dialog --stdout --menu "$(translate 'Select the remote USB device:')" 20 95 10 "${choices[@]}") || return
local_port=$(usbip_dialog --stdout --inputbox "$(translate 'Unused local loopback TCP port for this device:')" 9 75 "33240") || return
usbip_action add-import --host "$host" --ssh-port "$ssh_port" --key "$key" --export-id "$selected" --local-port "$local_port"
}
while true; do
option=$(usbip_dialog --stdout --menu "$(translate 'Share selected USB devices through SSH, or connect this Linux machine to a USB/IP server.')" 22 95 12 \
"1" "$(translate 'View USB/IP status')" \
"2" "$(translate 'Enable USB/IP server')" \
"3" "$(translate 'Share a local USB device')" \
"4" "$(translate 'Stop sharing a USB device')" \
"5" "$(translate 'Disable USB/IP server')" \
"6" "$(translate 'Connect a remote USB device')" \
"7" "$(translate 'Remove a remote USB connection')" \
"8" "$(translate 'Linux guest setup and migration notes')" \
"9" "$(translate 'Authorize a client SSH public key')" \
"0" "$(translate 'Back')") || break
case "$option" in
1) usbip_status ;;
2) usbip_dependencies server && usbip_action server-enable ;;
3) usbip_add_export ;;
4)
selected=$(usbip_select_saved exports) || continue
usbip_dialog --yesno "$(translate 'Stop sharing this device? An attached remote client will disconnect.')\n\n$selected" 10 80 && usbip_action remove-export "$selected"
;;
5)
usbip_dialog --yesno "$(translate 'Disable the server and release all USB devices managed by ProxMenux? Saved selections are kept for the next enable.')" 11 80 && usbip_action server-disable
;;
6) usbip_add_import ;;
7)
selected=$(usbip_select_saved imports) || continue
usbip_dialog --yesno "$(translate 'Remove this connection and detach its USB device?')\n\n$selected" 10 75 && usbip_action remove-import "$selected"
;;
8)
usbip_dialog --msgbox "$(translate 'Run the client inside the Linux VM that needs the USB device. Install usbip, openssh-client and kmod, then copy usbip_manager.py to the same path as on the server. Configure key authentication and verify the server host key before adding a connection. The client uses the guest kernel and can reconnect after migration while the physical USB device stays on the server. Unprivileged LXC and Home Assistant OS need their own kernel/device or add-on setup. A physical device supports one attached client at a time.')" 19 95
;;
9)
key=$(usbip_dialog --stdout --inputbox "$(translate 'Path to the client SSH public key on this server:')" 9 85) || continue
usbip_action authorize-key "$key"
;;
0) break ;;
esac
done
+410
View File
@@ -0,0 +1,410 @@
"""Authorize USB/IP control requests before exposing the native daemon.
The Unix listener runs in the server's private network namespace. Root supplies
current selection/ownership; the forced-command SSH account only reaches this
listener. An accepted import becomes an opaque, bounded, full-duplex URB stream.
"""
from contextlib import contextmanager
import os
from pathlib import Path
import re
import selectors
import socket
import socketserver
import struct
import threading
import time
VERSION = 0x0111
IMPORT = 0x8003
DEVLIST = 0x8005
HEADER = struct.Struct("!HHI")
DEVICE = struct.Struct("!256s32sIIIHHHBBBBBB")
BUSID = re.compile(r"[0-9]+-[0-9]+(?:\.[0-9]+)*\Z")
CONTROL_SECONDS = 5
MAX_DEVICES = 256
MAX_BUFFER = 65536
class ProtocolError(Exception):
pass
def receive(connection, count, deadline):
result = bytearray()
while len(result) < count:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ProtocolError("USB/IP control request timed out")
connection.settimeout(remaining)
block = connection.recv(count - len(result))
if not block:
raise ProtocolError("Incomplete USB/IP control request")
result.extend(block)
return bytes(result)
def busid(value):
"""One canonical NUL-terminated 32-byte Linux bus identifier."""
if len(value) != 32 or b"\0" not in value:
raise ProtocolError("Invalid USB/IP bus identifier")
name, padding = value.split(b"\0", 1)
if any(padding):
raise ProtocolError("Invalid USB/IP bus identifier padding")
try:
name = name.decode("ascii")
except UnicodeDecodeError as exc:
raise ProtocolError("Non-ASCII USB/IP bus identifier") from exc
if not BUSID.fullmatch(name):
raise ProtocolError("Invalid USB/IP bus identifier")
return name
class PinnedDevice:
"""Pin the sysfs object used for identity checks across the backend import.
This does not claim to hold usb_device's kernel lifetime. It detects a
removed/replaced sysfs object; USBFS lifetime pins belong to the owner.
"""
def __init__(self, root, name, expected):
self.path = Path(root) / name
self.fd = os.open(self.path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
try:
self.object = self._object(os.fstat(self.fd))
self.expected = {
key: str(expected[key]) for key in ("vendor", "product", "serial")
}
self.identity = self._identity()
self.validate()
except BaseException:
os.close(self.fd)
raise
@staticmethod
def _object(info):
return info.st_dev, info.st_ino
def read(self, name):
descriptor = os.open(
name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW, dir_fd=self.fd
)
try:
value = os.read(descriptor, 513)
if len(value) > 512:
raise ProtocolError("Oversized USB identity attribute")
return value.decode("utf-8").strip()
finally:
os.close(descriptor)
def _identity(self):
try:
serial = self.read("serial")
except FileNotFoundError:
serial = ""
classes = [int(self.read("bDeviceClass"), 16)]
for name in os.listdir(self.fd):
if name.startswith(self.path.name + ":"):
classes.append(int(self.read(name + "/bInterfaceClass"), 16))
if any(value in (8, 9) for value in classes):
raise ProtocolError("USB hubs and mass storage cannot be shared")
return {
"vendor": self.read("idVendor").lower(),
"product": self.read("idProduct").lower(),
"serial": serial,
"busnum": int(self.read("busnum")),
"devnum": int(self.read("devnum")),
"classes": tuple(sorted(classes)),
}
def validate(self, descriptor=None):
if (
self.object != self._object(os.stat(self.path))
or self._identity() != self.identity
):
raise ProtocolError("USB device was removed or replaced")
if any(self.identity[key] != value for key, value in self.expected.items()):
raise ProtocolError("USB device identity is not selected")
if Path(os.readlink("driver", dir_fd=self.fd)).name != "usbip-host":
raise ProtocolError("Selected USB device is no longer exported")
if descriptor is not None:
values = DEVICE.unpack(descriptor)
if (
busid(values[1]) != self.path.name
or values[2] != self.identity["busnum"]
or values[3] != self.identity["devnum"]
or values[5] != int(self.identity["vendor"], 16)
or values[6] != int(self.identity["product"], 16)
):
raise ProtocolError(
"USB/IP reply does not describe the selected device"
)
def close(self):
os.close(self.fd)
def relay(client, backend, stopped):
"""Preserve half-close and backpressure without an idle-session timeout."""
peers = (client, backend)
queued = [bytearray(), bytearray()]
reading = [True, True]
write_closed = [False, False]
for connection in peers:
connection.setblocking(False)
with selectors.DefaultSelector() as selector:
while not stopped.is_set():
for index, connection in enumerate(peers):
other = 1 - index
if not reading[other] and not queued[index] and not write_closed[index]:
connection.shutdown(socket.SHUT_WR)
write_closed[index] = True
events = (
selectors.EVENT_READ
if reading[index] and len(queued[other]) < MAX_BUFFER
else 0
) | (selectors.EVENT_WRITE if queued[index] else 0)
try:
selector.get_key(connection)
except KeyError:
if events:
selector.register(connection, events, index)
else:
if events:
selector.modify(connection, events, index)
else:
selector.unregister(connection)
if not selector.get_map():
return
for key, events in selector.select(timeout=1):
index = key.data
other = 1 - index
if events & selectors.EVENT_READ:
try:
block = peers[index].recv(MAX_BUFFER - len(queued[other]))
except BlockingIOError:
continue
if block:
queued[other].extend(block)
else:
reading[index] = False
if events & selectors.EVENT_WRITE:
try:
sent = peers[index].send(queued[index])
except BlockingIOError:
continue
if sent == 0:
raise ProtocolError("USB/IP connection closed during transfer")
del queued[index][:sent]
class Gate:
"""A bounded Unix server with the process-like lifecycle used by serve()."""
def __init__(
self,
path,
group,
claim,
sysfs="/sys/bus/usb/devices",
backend=("127.0.0.1", 3240),
max_connections=64,
):
self.path = Path(path)
self.claim = claim
self.sysfs = Path(sysfs)
self.backend = backend
self.stopped = threading.Event()
self.lock = threading.Lock()
self.connections = set()
self.slots = threading.BoundedSemaphore(max_connections)
gate = self
class Handler(socketserver.BaseRequestHandler):
def handle(self):
gate.handle(self.request)
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
def process_request(self, request, address):
if not gate.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, address)
except BaseException:
gate.slots.release()
raise
def process_request_thread(self, request, address):
try:
super().process_request_thread(request, address)
finally:
gate.slots.release()
self.server = Server(str(self.path), Handler)
try:
os.chown(self.path, 0, group)
self.path.chmod(0o660)
self.thread = threading.Thread(
target=self.server.serve_forever,
kwargs={"poll_interval": 0.1},
daemon=True,
)
self.thread.start()
except BaseException:
self.server.server_close()
self.path.unlink(missing_ok=True)
raise
@contextmanager
def pin(self, name):
expected = self.selection(name)
if not expected:
raise ProtocolError("USB device is not selected for sharing")
device = PinnedDevice(self.sysfs, name, expected)
try:
yield device, expected
finally:
device.close()
def _still_selected(self, name, expected):
if self.selection(name) != expected:
raise ProtocolError("USB sharing selection changed")
def selection(self, name):
value = self.claim(name)
if not value:
return None
# Freeze the authorization snapshot even when a callback reuses its
# dictionary; mutable ownership changes must not rewrite this lease.
return {key: str(value[key]) for key in ("id", "vendor", "product", "serial")}
@contextmanager
def tracked(self, connection, close=True):
with self.lock:
if self.stopped.is_set():
connection.close()
raise ProtocolError("USB/IP server is stopping")
self.connections.add(connection)
try:
yield connection
finally:
with self.lock:
self.connections.discard(connection)
if close:
connection.close()
def _backend(self, request, deadline):
connection = socket.create_connection(
self.backend, timeout=max(0.001, deadline - time.monotonic())
)
try:
connection.sendall(request)
return connection
except BaseException:
connection.close()
raise
@staticmethod
def response(connection, code, deadline):
header = receive(connection, HEADER.size, deadline)
version, received_code, status = HEADER.unpack(header)
if version != VERSION or received_code != (code & 0x7FFF) or status > 5:
raise ProtocolError("Invalid USB/IP backend response")
return header, status
def handle(self, client):
code = None
try:
with self.tracked(client, close=False):
deadline = time.monotonic() + CONTROL_SECONDS
request = receive(client, HEADER.size, deadline)
version, code, status = HEADER.unpack(request)
if version != VERSION or code not in (DEVLIST, IMPORT) or status:
code = None
raise ProtocolError("Unsupported USB/IP control request")
if code == IMPORT:
raw_name = receive(client, 32, deadline)
name = busid(raw_name)
with self.pin(name) as (device, expected):
with self.tracked(
self._backend(request + raw_name, deadline)
) as backend:
header, failed = self.response(backend, code, deadline)
if failed:
code = None
client.sendall(header)
return
descriptor = receive(backend, DEVICE.size, deadline)
self._still_selected(name, expected)
device.validate(descriptor)
code = (
None # Data mode: never append another control header.
)
client.sendall(header + descriptor)
relay(client, backend, self.stopped)
else:
with self.tracked(self._backend(request, deadline)) as backend:
header, failed = self.response(backend, code, deadline)
if failed:
code = None
client.sendall(header)
return
count = struct.unpack("!I", receive(backend, 4, deadline))[0]
if count > MAX_DEVICES:
raise ProtocolError(
"USB/IP backend device count is too large"
)
allowed = []
for _ in range(count):
descriptor = receive(backend, DEVICE.size, deadline)
values = DEVICE.unpack(descriptor)
interfaces = receive(backend, 4 * values[-1], deadline)
name = busid(values[1])
try:
with self.pin(name) as (device, expected):
self._still_selected(name, expected)
device.validate(descriptor)
allowed.append(descriptor + interfaces)
except (
ProtocolError,
OSError,
ValueError,
KeyError,
UnicodeError,
):
continue
code = None
client.sendall(
header + struct.pack("!I", len(allowed)) + b"".join(allowed)
)
except (ProtocolError, OSError, ValueError, KeyError, UnicodeError):
if code in (DEVLIST, IMPORT):
try:
client.sendall(HEADER.pack(VERSION, code & 0x7FFF, 1))
except OSError:
pass
finally:
client.close()
def poll(self):
return None if not self.stopped.is_set() and self.thread.is_alive() else 1
def terminate(self):
self.stopped.set()
self.server.shutdown()
with self.lock:
for connection in self.connections:
try:
connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.server.server_close()
self.path.unlink(missing_ok=True)
def wait(self, timeout=5):
self.thread.join(timeout)
return 0
+35
View File
@@ -92,6 +92,40 @@ remove_registry_logins() {
rmdir /etc/pve/priv/proxmenux 2>/dev/null || true
}
remove_usbip_sharing() {
local manager="$BASE_DIR/scripts/devices/usbip_manager.py"
local config="/etc/proxmenux/usbip/config.json" id unit
if [[ -f "$manager" && -f "$config" ]] && command -v jq >/dev/null 2>&1; then
for id in $(jq -r '.imports[]?.id' "$config" 2>/dev/null); do
pmx_record_execution "Remove USB/IP client connection" "python3 $manager remove-import $id"
python3 "$manager" remove-import "$id" >/dev/null 2>&1 || true
done
pmx_record_execution "Disable USB/IP server" "python3 $manager server-disable"
python3 "$manager" server-disable >/dev/null 2>&1 || true
fi
for unit in /etc/systemd/system/proxmenux-usbip-*.service; do
[[ -f "$unit" ]] && grep -q '^# Managed by ProxMenux USB/IP' "$unit" || continue
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
pmx_remove_file "$unit"
done
systemctl daemon-reload >/dev/null 2>&1 || true
# The transport account holds the authorized client keys. Remove it only
# when ProxMenux created it, so a reinstall does not revive old clients.
local home=/var/lib/proxmenux-usbip
if getent passwd proxmenux-usbip >/dev/null && [[ -f "$home/.proxmenux-owned" ]]; then
pkill -KILL -u proxmenux-usbip >/dev/null 2>&1 || true
pmx_record_execution "Remove USB/IP transport account" "userdel proxmenux-usbip"
userdel proxmenux-usbip >/dev/null 2>&1 || true
getent group proxmenux-usbip >/dev/null && groupdel proxmenux-usbip >/dev/null 2>&1
rm -rf "$home"
fi
if [[ -d /etc/proxmenux/usbip ]]; then
pmx_record_execution "Remove USB/IP configuration" "rm -rf /etc/proxmenux/usbip"
rm -rf /etc/proxmenux/usbip
fi
rmdir /etc/proxmenux 2>/dev/null || true
}
uninstall_proxmenux_monitor() {
# 1. Stop service if it is running
@@ -816,6 +850,7 @@ uninstall_proxmenu() {
fi
echo "50" ; echo "Removing ProxMenu files..."
remove_usbip_sharing
pmx_remove_file "$INSTALL_DIR/$MENU_SCRIPT"
pmx_record_execution "Remove ProxMenux application directory" "rm -rf $BASE_DIR"
rm -rf "$BASE_DIR"
+4
View File
@@ -43,6 +43,7 @@ while true; do
"" "" \
"" "\Z4───────────────────── $(translate "Utilities") ────────────────────────\Zn" \
"7" "$(translate "Manual CLI Guide (GPU/TPU)")" \
"8" "$(translate "USB/IP over SSH")" \
"0" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty
) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; }
@@ -69,6 +70,9 @@ while true; do
7)
bash "$LOCAL_SCRIPTS/gpu_tpu/gpu-tpu-manual-guide.sh"
;;
8)
bash "$LOCAL_SCRIPTS/devices/usbip_menu.sh"
;;
0)
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
;;
+6
View File
@@ -1,4 +1,10 @@
[
{
"title": "USB/IP over SSH",
"menu": "Hardware: GPUs and Coral-TPU",
"script": "devices/usbip_menu.sh",
"keywords": "usbip usb ip ssh remote zigbee bluetooth printer impresora remoto"
},
{
"title": "Settings post-install Proxmox",
"menu": "Settings post-install Proxmox",
+131
View File
@@ -0,0 +1,131 @@
"""Exercise native architecture wiring without building or changing the host."""
import importlib.util
import struct
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / 'AppImage/scripts'
class AppImageArchitectureTests(unittest.TestCase):
def configure(self, machine):
return subprocess.run(['bash', '-c',
'source "$1"; pmx_appimage_arch "$2" || exit; '
'printf "%s|%s|%s" "$PMX_APPIMAGE_ARCH" "$PMX_APPIMAGE_TRIPLET" "$PMX_APPIMAGE_SUFFIX"',
'fixture', str(SCRIPTS / 'appimage_arch.sh'), machine], capture_output=True, text=True)
def test_elf_audit_rejects_foreign_binary_without_executing_it(self):
spec = importlib.util.spec_from_file_location('appimage_elf_check', ROOT / '.github/scripts/check_appimage_elf.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
with tempfile.TemporaryDirectory() as folder:
binary = Path(folder) / 'fixture-elf'
header = bytearray(20)
header[:6] = b'\x7fELF\x02\x01'
header[18:20] = struct.pack('<H', 183)
binary.write_bytes(header)
self.assertEqual(module.check(folder, 'aarch64'), 1)
with self.assertRaisesRegex(ValueError, 'Foreign ELF machine 183'):
module.check(folder, 'x86_64')
binary.write_text('#!/bin/sh\nexit 127\n')
with self.assertRaisesRegex(ValueError, 'No native ELF files'):
module.check(folder, 'aarch64')
def test_supported_native_architectures_and_aliases(self):
for machine in ['x86_64', 'amd64']:
self.assertEqual(self.configure(machine).stdout, 'x86_64|x86_64-linux-gnu|')
for machine in ['aarch64', 'arm64']:
self.assertEqual(self.configure(machine).stdout, 'aarch64|aarch64-linux-gnu|-aarch64')
def test_unsupported_architecture_fails_before_build_side_effects(self):
with tempfile.TemporaryDirectory() as folder:
binary = Path(folder) / 'uname'
binary.write_text('#!/bin/sh\necho loongarch64\n')
binary.chmod(0o755)
result = subprocess.run(['bash', str(SCRIPTS / 'build_appimage.sh')],
env={**os.environ, 'PATH': folder + ':' + os.environ['PATH']}, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Unsupported Monitor AppImage architecture: loongarch64', result.stderr)
self.assertNotIn('Building ProxMenux', result.stdout)
def test_gevent_wheel_installer_uses_native_platform_for_both_abis(self):
source = (SCRIPTS / 'build_appimage.sh').read_text()
function = source[source.index('install_gevent_runtime() {'):source.index('\ninstall_gevent_runtime "3.11"')]
for machine in ['x86_64', 'aarch64']:
with tempfile.TemporaryDirectory() as folder:
command = ('source "$1"; pmx_appimage_arch "$2"; APP_DIR="$3"; '
'python3() { printf "%s\\n" "$*"; }; ' + function +
'\ninstall_gevent_runtime 3.11 cp311\ninstall_gevent_runtime 3.13 cp313')
result = subprocess.run(['bash', '-c', command, 'fixture', str(SCRIPTS / 'appimage_arch.sh'), machine, folder],
capture_output=True, text=True, check=True)
for platform in ['manylinux_2_28_', 'manylinux_2_27_', 'manylinux2014_']:
self.assertEqual(result.stdout.count('--platform ' + platform + machine), 2)
for abi in ['cp311', 'cp313']:
self.assertIn('--abi ' + abi, result.stdout)
def test_apprun_uses_native_library_paths_and_matching_host_python_tree(self):
for machine in ['x86_64', 'aarch64']:
with tempfile.TemporaryDirectory() as folder:
app = Path(folder)
(app / 'usr/bin').mkdir(parents=True)
(app / 'usr/lib/python3/vendor/cp311').mkdir(parents=True)
shutil.copy(SCRIPTS / 'appimage_arch.sh', app / 'usr/bin')
shutil.copy(SCRIPTS / 'AppRun', app / 'AppRun')
for name, body in {
'uname': 'echo ' + machine,
'readlink': 'printf "%s\\n" "$2"',
'python3': 'if [ "$1" = -c ]; then echo cp311; else printf "LIB=%s\\nPY=%s\\n" "$LD_LIBRARY_PATH" "$PYTHONPATH"; fi',
}.items():
file = app / 'usr/bin' / name
file.write_text('#!/bin/sh\n' + body + '\n')
file.chmod(0o755)
result = subprocess.run(['bash', str(app / 'AppRun'), '--translate'],
env={**os.environ, 'PATH': str(app / 'usr/bin') + ':' + os.environ['PATH']},
capture_output=True, text=True, check=True)
self.assertIn(str(app / ('usr/lib/' + machine + '-linux-gnu')), result.stdout)
self.assertIn(str(app / 'usr/lib/python3/vendor/cp311'), result.stdout)
def test_arm_borg_adapter_reports_unavailable_or_uses_native_host_package(self):
source = (SCRIPTS / 'borg_native_arm.sh').read_text()
with tempfile.TemporaryDirectory() as folder:
native = Path(folder) / 'borg'
adapter = Path(folder) / 'adapter'
adapter.write_text(source.replace('/usr/bin/borg', str(native)))
missing = subprocess.run(['sh', str(adapter), '--version'], capture_output=True, text=True)
self.assertEqual(missing.returncode, 127)
self.assertIn('Install the native borgbackup package', missing.stderr)
native.write_text('#!/bin/sh\nprintf "native %s|%s|%s|%s" "$*" "${PYTHONPATH-unset}" "${PYTHONHOME-unset}" "${LD_LIBRARY_PATH-unset}"\n')
native.chmod(0o755)
installed = subprocess.run(['sh', str(adapter), 'list', 'fixture-repo'],
env={**os.environ, 'PYTHONPATH': '/fixture/appimage/python', 'PYTHONHOME': '/fixture/appimage/home',
'LD_LIBRARY_PATH': '/fixture/appimage/lib'}, capture_output=True, text=True, check=True)
self.assertEqual(installed.stdout, 'native list fixture-repo|unset|unset|unset')
def test_arm_host_backup_never_selects_cached_amd64_borg_or_downloads_it(self):
source = (ROOT / 'scripts/backup_restore/lib_host_backup_common.sh').read_text()
function = source[source.index('hb_ensure_borg() {'):source.index('\nhb_borg_init_if_needed()')]
with tempfile.TemporaryDirectory() as folder:
cached = Path(folder) / 'borg'
cached.write_text('#!/bin/sh\nexit 0\n')
cached.chmod(0o755)
script = (function + '\n'
'command() { if [ "$*" = "-v borg" ]; then return 1; fi; builtin command "$@"; }; '
'uname() { echo aarch64; }; hb_translate() { printf "%s" "$1"; }; '
'msg_error() { printf "%s" "$1" >&2; }; '
'wget() { echo UNEXPECTED_DOWNLOAD >&2; return 99; }; '
'HB_STATE_DIR="$1"; hb_ensure_borg')
result = subprocess.run(['bash', '-c', script, 'fixture', folder], capture_output=True, text=True)
self.assertEqual(result.returncode, 1)
self.assertIn('Native Borg is unavailable.', result.stderr)
self.assertNotIn('UNEXPECTED_DOWNLOAD', result.stderr)
self.assertEqual(result.stdout, '')
if __name__ == '__main__':
unittest.main()