Explorar el Código

Fix SpoolBuddy update Docker failure — set LOGNAME/USER/HOME in image

  Follow-up to the asyncssh migration. asyncssh.connect() internally
  calls getpass.getuser() for ~/.ssh/config host matching, regardless
  of the explicit `username=` passed for the remote login. Under an
  arbitrary Docker PUID with no /etc/passwd entry, getpass.getuser()
  raises "No username set in the environment" (OSError in Python 3.13+,
  previously a bare KeyError).

  Fix: set LOGNAME=bambuddy, USER=bambuddy, HOME=/app in the Dockerfile.
  getpass.getuser() tries env vars before pwd.getpwuid(), so the lookup
  never touches the passwd database and works for any PUID the operator
  picks — no helper code, no image rebuild for different UIDs.

  Also pass config=[] to asyncssh.connect() so it does not try to load
  ~/.ssh/config (whose default path needs a resolvable home directory).

  An earlier draft of this fix added a Python helper that caught the
  KeyError and injected LOGNAME at module import. That was both more
  code than needed and broken on Python 3.13, which wraps the KeyError
  in an OSError the helper didn't catch — so the module import itself
  crashed, producing a 500 on /spoolbuddy/devices/{id}/update. Reverted
  in favour of the one-line ENV fix.
maziggy hace 4 meses
padre
commit
44cb26c7c3

+ 1 - 1
CHANGELOG.md

@@ -13,7 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### Fixed
 ### Fixed
 - **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{"detail":"Valid camera stream token required..."}`. The sidebar `<img>` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `<img>` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled.
 - **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{"detail":"Valid camera stream token required..."}`. The sidebar `<img>` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `<img>` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled.
 - **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.
 - **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.
-- **SpoolBuddy Update Fails in Docker with "no user exists for uid 1000"** — The SpoolBuddy remote-update flow shelled out to the OpenSSH `ssh-keygen` and `ssh` binaries for keypair creation and command execution. Both of those binaries call `getpwuid(getuid())` during startup and abort with `No user exists for uid <N>` when the container runs under an arbitrary PUID that is not listed in `/etc/passwd` (the default `python:3.13-slim` image only has an entry for root, so running with `user: "1000:1000"` — or any non-root user — tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the `cryptography` library (already a dependency), SSH commands run through the pure-Python `asyncssh` client, and git-branch detection reads `.git/HEAD` directly instead of shelling out to `git`. asyncssh internally calls `getpass.getuser()` to resolve the *local* username for `~/.ssh/config` host matching, which hit the same missing-passwd-entry failure (asyncssh reported it as `SSH connection failed: no username set in the environment`); this is now worked around by setting `LOGNAME=bambuddy` at module import when neither `LOGNAME`/`USER`/`LNAME`/`USERNAME` nor the passwd lookup resolves a name, and by explicitly passing `config=[]` to skip the `~/.ssh/config` load (which also needs a resolvable home directory). Native installs behave identically — they already worked because the running user was always in `/etc/passwd`. Regression tests assert that neither keypair creation nor command execution spawns any subprocess, and that the local-username fallback fires only when the passwd lookup actually fails.
+- **SpoolBuddy Update Fails in Docker with "no user exists for uid 1000/1001"** — The SpoolBuddy remote-update flow shelled out to the OpenSSH `ssh-keygen` and `ssh` binaries for keypair creation and command execution. Both binaries call `getpwuid(getuid())` at startup and abort with `No user exists for uid <N>` when the container runs under an arbitrary PUID that is not listed in `/etc/passwd` (the stock `python:3.13-slim` image only has an entry for root, so running with `user: "1000:1000"`, `"1001:1001"`, or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the `cryptography` library (already a dependency), SSH commands run through the pure-Python `asyncssh` client, and git-branch detection reads `.git/HEAD` directly instead of shelling out to `git`. asyncssh also calls `getpass.getuser()` for local `~/.ssh/config` host matching, which hit the same passwd lookup failure; the Docker image now sets `LOGNAME=bambuddy`, `USER=bambuddy`, and `HOME=/app` so `getpass.getuser()` resolves via env vars before touching the passwd database, and `asyncssh.connect()` is called with `config=[]` so it does not attempt to load `~/.ssh/config` at all. Native installs behave identically — they already worked because the running user was always in `/etc/passwd`. A regression test asserts that neither keypair creation nor command execution spawns any subprocess.
 - **Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation.
 - **Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation.
 - **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
 - **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
 - **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
 - **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.

+ 11 - 0
Dockerfile

@@ -53,6 +53,17 @@ ENV PYTHONUNBUFFERED=1
 ENV DATA_DIR=/app/data
 ENV DATA_DIR=/app/data
 ENV LOG_DIR=/app/logs
 ENV LOG_DIR=/app/logs
 ENV PORT=8000
 ENV PORT=8000
+# Provide a local username + home for tools that call getpass.getuser() /
+# os.path.expanduser() under arbitrary PUIDs. With `user: "1001:1001"` the
+# stock python:3.13-slim image has no /etc/passwd entry for that UID, so
+# pwd.getpwuid() raises and breaks libraries that do host-level user lookups
+# (notably asyncssh, which uses the local username for ~/.ssh/config host
+# matching during the SpoolBuddy remote-update flow). Setting LOGNAME/USER
+# makes getpass.getuser() resolve via env vars instead of the passwd db;
+# HOME=/app gives a writable home that is guaranteed to exist.
+ENV HOME=/app
+ENV USER=bambuddy
+ENV LOGNAME=bambuddy
 
 
 EXPOSE 322
 EXPOSE 322
 EXPOSE 990
 EXPOSE 990

+ 5 - 33
backend/app/services/spoolbuddy_ssh.py

@@ -13,7 +13,6 @@ entries for root). asyncssh does all of its work in-process.
 """
 """
 
 
 import asyncio
 import asyncio
-import getpass
 import logging
 import logging
 import os
 import os
 from pathlib import Path
 from pathlib import Path
@@ -29,38 +28,11 @@ logger = logging.getLogger(__name__)
 SSH_USER = "spoolbuddy"
 SSH_USER = "spoolbuddy"
 DEFAULT_INSTALL_PATH = "/opt/bambuddy"
 DEFAULT_INSTALL_PATH = "/opt/bambuddy"
 
 
-
-def _ensure_local_username_env() -> None:
-    """Make `getpass.getuser()` succeed even when the process runs under a UID
-    that is not listed in /etc/passwd.
-
-    asyncssh.connect() unconditionally calls `getpass.getuser()` to resolve
-    the *local* username (used for `~/.ssh/config` host matching, not the
-    remote login name). `getpass.getuser()` reads `LOGNAME`/`USER`/`LNAME`/
-    `USERNAME` first and falls back to `pwd.getpwuid(os.getuid())`. Inside a
-    Docker container with an arbitrary PUID (e.g. 1000 on `python:3.13-slim`,
-    which only has a root passwd entry), none of those env vars are set and
-    the pwd lookup raises `KeyError`, causing asyncssh to abort with
-    "Unknown local username: set one of LOGNAME, USER, LNAME, or USERNAME in
-    the environment".
-
-    If the lookup already works, or the operator has any of those env vars
-    set, this is a no-op. Otherwise we set a harmless `LOGNAME` default so
-    asyncssh can proceed. This only affects the resolution of the *local*
-    username; the SSH login user is always passed explicitly as `SSH_USER`.
-    """
-    try:
-        getpass.getuser()
-        return
-    except KeyError:
-        pass
-
-    if not any(os.environ.get(k) for k in ("LOGNAME", "USER", "LNAME", "USERNAME")):
-        os.environ["LOGNAME"] = "bambuddy"
-        logger.debug("Set LOGNAME=bambuddy for asyncssh (container UID has no /etc/passwd entry)")
-
-
-_ensure_local_username_env()
+# Note for Docker: asyncssh.connect() internally calls getpass.getuser() to
+# resolve the *local* username for ~/.ssh/config host matching. Under an
+# arbitrary PUID with no /etc/passwd entry this would raise OSError. The
+# Dockerfile sets LOGNAME/USER/HOME so getpass.getuser() succeeds via env-var
+# lookup before ever touching the passwd database.
 
 
 
 
 def _get_ssh_key_dir() -> Path:
 def _get_ssh_key_dir() -> Path:

+ 0 - 44
backend/tests/unit/services/test_spoolbuddy_ssh.py

@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
 import pytest
 import pytest
 
 
 from backend.app.services.spoolbuddy_ssh import (
 from backend.app.services.spoolbuddy_ssh import (
-    _ensure_local_username_env,
     _get_ssh_key_dir,
     _get_ssh_key_dir,
     _run_ssh_command,
     _run_ssh_command,
     detect_current_branch,
     detect_current_branch,
@@ -180,49 +179,6 @@ def test_detect_branch_default_main(tmp_path):
         assert detect_current_branch() == "main"
         assert detect_current_branch() == "main"
 
 
 
 
-# -- _ensure_local_username_env ------------------------------------------------
-
-
-def test_ensure_local_username_env_noop_when_getuser_works():
-    """When getpass.getuser() succeeds, the env must not be mutated."""
-    # Stash the current values so we can detect mutation.
-    before = {k: os.environ.get(k) for k in ("LOGNAME", "USER", "LNAME", "USERNAME")}
-    with patch("backend.app.services.spoolbuddy_ssh.getpass.getuser", return_value="realuser"):
-        _ensure_local_username_env()
-    after = {k: os.environ.get(k) for k in ("LOGNAME", "USER", "LNAME", "USERNAME")}
-    assert before == after
-
-
-def test_ensure_local_username_env_sets_logname_when_getuser_fails(monkeypatch):
-    """When getpass.getuser() raises KeyError AND no USER/LOGNAME/etc is set,
-    LOGNAME must be populated so asyncssh.connect() can proceed.
-
-    Regression guard for the Docker/PUID failure mode: asyncssh's connect()
-    calls getpass.getuser() unconditionally for ~/.ssh/config host matching,
-    and raises 'Unknown local username: set one of LOGNAME, USER, LNAME, or
-    USERNAME in the environment' when pwd.getpwuid() fails under an
-    arbitrary PUID not listed in /etc/passwd.
-    """
-    for key in ("LOGNAME", "USER", "LNAME", "USERNAME"):
-        monkeypatch.delenv(key, raising=False)
-    with patch("backend.app.services.spoolbuddy_ssh.getpass.getuser", side_effect=KeyError("no passwd entry")):
-        _ensure_local_username_env()
-    assert os.environ.get("LOGNAME") == "bambuddy"
-
-
-def test_ensure_local_username_env_respects_existing_env(monkeypatch):
-    """If the operator has set USER (or any of the fallback vars) but the
-    passwd lookup still fails, we must leave their value alone."""
-    monkeypatch.delenv("LOGNAME", raising=False)
-    monkeypatch.delenv("LNAME", raising=False)
-    monkeypatch.delenv("USERNAME", raising=False)
-    monkeypatch.setenv("USER", "operator")
-    with patch("backend.app.services.spoolbuddy_ssh.getpass.getuser", side_effect=KeyError):
-        _ensure_local_username_env()
-    assert os.environ.get("USER") == "operator"
-    assert os.environ.get("LOGNAME") is None
-
-
 # -- _run_ssh_command ----------------------------------------------------------
 # -- _run_ssh_command ----------------------------------------------------------
 #
 #
 # _run_ssh_command uses asyncssh (pure Python) rather than the OpenSSH `ssh`
 # _run_ssh_command uses asyncssh (pure Python) rather than the OpenSSH `ssh`