diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7f24131 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md + +## Layout +- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`. +- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`). +- Frontend entrypoint: `frontend/src/main.tsx`. +- Backend uses a `src/` layout; tests live in `backend/tests/`. + +## Commands +- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'` +- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`. +- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k `; if the package is not installed, use `PYTHONPATH=src pytest`. +- Frontend setup: `cd frontend && npm install` +- Frontend dev/build/lint: `npm run dev`, `npm run build`, `npm run lint`; `npm run build` already typechecks via `tsc -b`. +- Focused frontend typecheck: `npx tsc --noEmit` +- Local dev stack: `docker compose -f docker-compose.dev.yml up --build` +- Production stack: `docker compose up --build` + +## Repo-Specific Gotchas +- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them. +- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars). +- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines. +- `backend_cache` persists the media index and the managed `known_hosts` file. +- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`. +- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and monitoring poller. +- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically. +- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact. +- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11. +- Update `docs/REQUIREMENTS.md` whenever behavior, UX, or architecture changes. +- Do not commit `.env`, `.streamlit/secrets.toml`, private keys, or tokens. diff --git a/backend/src/media_library_viewer_api/clients/ssh.py b/backend/src/media_library_viewer_api/clients/ssh.py index de7049f..0dd27ed 100644 --- a/backend/src/media_library_viewer_api/clients/ssh.py +++ b/backend/src/media_library_viewer_api/clients/ssh.py @@ -20,8 +20,6 @@ from typing import Any import paramiko -from media_library_viewer_api.services.known_hosts import ensure_known_host - logger = logging.getLogger(__name__) @@ -66,26 +64,12 @@ class RemoteSSHClient: def connect(self) -> paramiko.SSHClient: """Create or reuse the Paramiko connection. - Unknown host keys are recorded on first contact in the managed - known_hosts file when one is configured. After that, strict checking - remains in effect so host key changes are still rejected. + Host keys are expected to be managed ahead of time by startup synthesis + or explicit validation flows. Runtime connections only load the managed + known_hosts file and then let Paramiko enforce strict checking. """ if self._client: return self._client - if self.known_hosts_path: - try: - ensure_known_host(self.host, self.port, Path(self.known_hosts_path), strict=True) - except Exception as exc: - message = str(exc).lower() - if "protocol banner" in message: - raise RuntimeError( - f"SSH banner not received from {self.host}:{self.port}. " - "The host key could not be recorded because the backend could not talk to SSH." - ) from exc - raise RuntimeError( - f"SSH host key lookup failed for {self.host}:{self.port}. " - "Confirm the host and port are correct and that SSH is reachable." - ) from exc client = paramiko.SSHClient() client.load_system_host_keys() if self.known_hosts_path and Path(self.known_hosts_path).is_file(): diff --git a/backend/tests/test_ssh_client.py b/backend/tests/test_ssh_client.py new file mode 100644 index 0000000..e22cd29 --- /dev/null +++ b/backend/tests/test_ssh_client.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from media_library_viewer_api.clients.ssh import RemoteSSHClient + + +def test_connect_uses_existing_known_hosts_without_reprobing(tmp_path): + known_hosts_path = tmp_path / "known_hosts" + known_hosts_path.write_text("example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAAFAKE\n") + + ssh_client = MagicMock() + ssh_client.connect.return_value = None + + with ( + patch("media_library_viewer_api.clients.ssh.paramiko.SSHClient", return_value=ssh_client), + patch("media_library_viewer_api.clients.ssh.paramiko.RejectPolicy", return_value=object()), + ): + client = RemoteSSHClient( + host="example.com", + username="alex", + known_hosts_path=str(known_hosts_path), + ) + + client.connect() + + ssh_client.load_system_host_keys.assert_called_once_with() + ssh_client.load_host_keys.assert_called_once_with(str(known_hosts_path)) + ssh_client.set_missing_host_key_policy.assert_called_once() + ssh_client.connect.assert_called_once()