diff --git a/.env.example b/.env.example index 59ff62b..37000f8 100644 --- a/.env.example +++ b/.env.example @@ -24,12 +24,11 @@ SMTP_TIMEOUT=30 SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_PORT=22 -# In Docker Compose, mount ./secrets/ssh to /root/.ssh inside the backend container. -# The app uses SSH_KEY_DIRECTORY + SSH_KEY_NAME to build the full key path. +# Host-side directory mounted into the backend container at /root/.ssh. +SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir +# Container-side path assembled by the app: SSH_KEY_DIRECTORY=/root/.ssh SSH_KEY_NAME=id_ed25519 -# Optional legacy override if you want to provide the full path directly. -# SSH_KEY_FILENAME=/root/.ssh/id_ed25519 # SSH_PASSWORD=optional-password-or-key-passphrase REMOTE_MEDIA_ROOT=/mnt/media # Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough. diff --git a/.gitignore b/.gitignore index 9727411..d000eae 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,6 @@ env/ *.log tmp/ -# SSH secrets used by docker compose -secrets/ssh/ - # Frontend frontend/node_modules/ frontend/dist/ diff --git a/README.md b/README.md index d89be03..051c0e7 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,11 @@ LOG_LEVEL=INFO SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_PORT=22 -# In Docker Compose, mount ./secrets/ssh to /root/.ssh inside the backend container. -# The app uses SSH_KEY_DIRECTORY + SSH_KEY_NAME to build the full key path. +# Host-side directory mounted into the backend container at /root/.ssh. +SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir +# Container-side path assembled by the app: SSH_KEY_DIRECTORY=/root/.ssh SSH_KEY_NAME=id_ed25519 -# Optional legacy override if you want to provide the full path directly. -# SSH_KEY_FILENAME=/root/.ssh/id_ed25519 SSH_PASSWORD= REMOTE_MEDIA_ROOT=/srv/media diff --git a/backend/README.md b/backend/README.md index 5911c92..446a4a6 100644 --- a/backend/README.md +++ b/backend/README.md @@ -71,12 +71,11 @@ OIDC_CLOCK_SKEW_SECONDS=30 SSH_HOST=media-server.example.com SSH_USERNAME=username SSH_PORT=22 -# In Docker Compose, mount ./secrets/ssh to /root/.ssh inside the backend container. -# The app uses SSH_KEY_DIRECTORY + SSH_KEY_NAME to build the full key path. +# Host-side directory mounted into the backend container at /root/.ssh. +SSH_KEY_HOST_DIR=/absolute/path/to/your/ssh-dir +# Container-side path assembled by the app: SSH_KEY_DIRECTORY=/root/.ssh SSH_KEY_NAME=id_ed25519 -# Optional legacy override if you want to provide the full path directly. -# SSH_KEY_FILENAME=/root/.ssh/id_ed25519 SSH_PASSWORD= REMOTE_MEDIA_ROOT=/srv/media diff --git a/backend/src/media_library_viewer_api/config.py b/backend/src/media_library_viewer_api/config.py index c996172..3f9dca6 100644 --- a/backend/src/media_library_viewer_api/config.py +++ b/backend/src/media_library_viewer_api/config.py @@ -55,9 +55,8 @@ class Settings(BaseSettings): ssh_host: str = "" ssh_username: str = "" ssh_port: int = 22 - ssh_key_directory: str = str(Path.home() / ".ssh") - ssh_key_name: str = "id_rsa" - ssh_key_filename: str = "" # legacy direct override (full path) + ssh_key_directory: str = "" + ssh_key_name: str = "" ssh_password: str = "" # Remote paths @@ -75,8 +74,8 @@ class Settings(BaseSettings): @property def ssh_key_path(self) -> str: - if self.ssh_key_filename: - return self.ssh_key_filename + if not self.ssh_key_directory or not self.ssh_key_name: + return "" return str(Path(self.ssh_key_directory) / self.ssh_key_name) model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index 8ff3f25..c7731d8 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -46,18 +46,21 @@ def get_ssh_client() -> RemoteSSHClient: """Return a cached SSH client (connects on first use).""" settings = get_settings() logger.info( - "Creating SSH client host=%s user=%s port=%s key=%s password=%s", + "Creating SSH client host=%s user=%s port=%s key_dir=%s key_name=%s password=%s", settings.ssh_host or "", settings.ssh_username or "", settings.ssh_port, - settings.ssh_key_path or "", + settings.ssh_key_directory or "", + settings.ssh_key_name or "", "set" if settings.ssh_password else "missing", ) + if not settings.ssh_key_path: + raise RuntimeError("SSH_KEY_DIRECTORY and SSH_KEY_NAME must be configured") client = RemoteSSHClient( host=settings.ssh_host, username=settings.ssh_username, port=settings.ssh_port, - key_filename=settings.ssh_key_path or None, + key_filename=settings.ssh_key_path, password=settings.ssh_password or None, ) try: diff --git a/backend/src/media_library_viewer_api/logging_utils.py b/backend/src/media_library_viewer_api/logging_utils.py index 287d76c..814c72d 100644 --- a/backend/src/media_library_viewer_api/logging_utils.py +++ b/backend/src/media_library_viewer_api/logging_utils.py @@ -54,7 +54,6 @@ def describe_settings(settings: object) -> dict[str, str]: "ssh_port": str(getattr(settings, "ssh_port", 22) or 22), "ssh_key_directory": getattr(settings, "ssh_key_directory", "") or "", "ssh_key_name": getattr(settings, "ssh_key_name", "") or "", - "ssh_key_filename": "set" if getattr(settings, "ssh_key_filename", "") else "missing", "ssh_password": "set" if getattr(settings, "ssh_password", "") else "missing", "smtp_host": _sanitize_url(getattr(settings, "smtp_host", "")), "smtp_port": str(getattr(settings, "smtp_port", 587) or 587), diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index e178b61..446a440 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -15,6 +15,9 @@ class TestSettings: assert settings.jellyseerr_url == "" assert settings.log_level == "INFO" assert settings.remote_media_root == "" + assert settings.ssh_key_directory == "" + assert settings.ssh_key_name == "" + assert settings.ssh_key_path == "" def test_from_env(self): env = { @@ -51,3 +54,9 @@ class TestSettings: with patch.dict(os.environ, env, clear=True): settings = Settings(_env_file=None) assert settings.path_prefix == "/srv" + + def test_ssh_key_path_joins_directory_and_name(self): + env = {"SSH_KEY_DIRECTORY": "/root/.ssh", "SSH_KEY_NAME": "id_ed25519"} + with patch.dict(os.environ, env, clear=True): + settings = Settings(_env_file=None) + assert settings.ssh_key_path == "/root/.ssh/id_ed25519" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fc05b87..74573b0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,12 +10,12 @@ services: environment: AUTH_ENABLED: "false" SSH_KEY_DIRECTORY: /root/.ssh - SSH_KEY_NAME: ${SSH_KEY_NAME:-id_ed25519} + SSH_KEY_NAME: ${SSH_KEY_NAME} ports: - "8000:8000" volumes: - ./backend:/app/backend - - ./secrets/ssh:/root/.ssh:ro + - ${SSH_KEY_HOST_DIR}:/root/.ssh:ro restart: unless-stopped frontend: diff --git a/docker-compose.yml b/docker-compose.yml index 5cd6ba5..fc8a063 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,9 @@ services: environment: AUTH_ENABLED: "true" SSH_KEY_DIRECTORY: /root/.ssh - SSH_KEY_NAME: ${SSH_KEY_NAME:-id_ed25519} + SSH_KEY_NAME: ${SSH_KEY_NAME} volumes: - - ${SSH_KEY_DIRECTORY:-./secrets/ssh}:/root/.ssh:ro + - ${SSH_KEY_HOST_DIR}:/root/.ssh:ro restart: unless-stopped networks: - web diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index a4f6490..631758e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -128,7 +128,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - Provide Docker Compose deployment files at the repository root for production and local development. - Backend Docker deployment should mount a private SSH key and a known_hosts file into the container rather than baking them into the image. - Production compose should also pass the root `.env` into the backend container so runtime auth settings like `OIDC_ISSUER_URL` are available there, not just at compose interpolation time. -- The SSH key configuration should support separate directory/name inputs so Docker Compose can mount an entire `./secrets/ssh` directory into `/root/.ssh` while the app assembles the full key path. +- The SSH key configuration should support separate directory/name inputs so Docker Compose can mount an arbitrary host SSH directory into `/root/.ssh` while the app assembles the full key path. - Show Jellyfin media counts for movies, series, and series episodes on the dashboard. - Show dashboard session activity from Jellyfin, including both currently playing sessions and logged-in idle sessions. - Activity rows should include user, media title (or `(idle)`), playback state (`playing`/`paused`/`idle`), and whether transcoding is active. @@ -171,4 +171,4 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-03: Confirmed the shared session table should keep the compact overall status summary line above the rows. - 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample. - 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows. -- 2026-05-04: Backend Docker Compose now mounts the entire `./secrets/ssh` directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image. +- 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image.