""" SFTP destination management — connection helpers and credential storage. Credentials live at /data/sftp/{id}.password (mode 600) or /data/sftp/{id}.key (also mode 600). Public host keys are pinned at /data/sftp/{id}.host_keys after the first successful connection (TOFU); subsequent connections fail loudly if the host key changes. """ import io import os import stat import errno from contextlib import contextmanager from typing import Optional import paramiko CRED_DIR = "/data/sftp" # ── Credential storage ─────────────────────────────────────────────────────── def _ensure_cred_dir() -> None: os.makedirs(CRED_DIR, mode=0o700, exist_ok=True) def _password_path(dest_id: int) -> str: return os.path.join(CRED_DIR, f"{dest_id}.password") def _key_path(dest_id: int) -> str: return os.path.join(CRED_DIR, f"{dest_id}.key") def _host_keys_path(dest_id: int) -> str: return os.path.join(CRED_DIR, f"{dest_id}.host_keys") def write_password(dest_id: int, password: str) -> None: _ensure_cred_dir() p = _password_path(dest_id) with open(p, "w") as f: f.write(password) os.chmod(p, 0o600) def write_private_key(dest_id: int, key_text: str) -> None: _ensure_cred_dir() p = _key_path(dest_id) with open(p, "w") as f: f.write(key_text if key_text.endswith("\n") else key_text + "\n") os.chmod(p, 0o600) def delete_credentials(dest_id: int) -> None: """Best-effort cleanup of all stored secrets for a destination.""" for p in (_password_path(dest_id), _key_path(dest_id), _host_keys_path(dest_id)): try: if os.path.exists(p): os.unlink(p) except Exception: pass def has_credentials(dest_id: int, auth_method: str) -> bool: if auth_method == "password": return os.path.isfile(_password_path(dest_id)) if auth_method == "key": return os.path.isfile(_key_path(dest_id)) return False # ── Keypair generation ────────────────────────────────────────────────────── def generate_keypair() -> tuple[str, str, str]: """Generate an ED25519 keypair. Returns (private_pem, public_openssh, fingerprint).""" key = paramiko.Ed25519Key.generate() priv_buf = io.StringIO() key.write_private_key(priv_buf) private_pem = priv_buf.getvalue() public_openssh = f"{key.get_name()} {key.get_base64()} dupfinder@miaai" fingerprint = key.fingerprint # SHA-256:base64 return private_pem, public_openssh, fingerprint # ── Connection ────────────────────────────────────────────────────────────── def _open_transport(dest: dict, timeout: int = 15) -> paramiko.Transport: """Open and authenticate a Transport directly. Bypasses SSHClient. Mirrors how OpenSSH/WinSCP invoke the SFTP subsystem without first allocating an exec channel — works around a "Channel closed" issue Synology DSM throws at SSHClient.open_sftp() but not at direct SFTPClient.from_transport(). """ import socket sock = socket.create_connection( (dest["host"], int(dest.get("port") or 22)), timeout=timeout, ) transport = paramiko.Transport(sock) # Generous flow-control windows — Synology sometimes closes mid-handshake # if the client's window is small. transport.default_window_size = 2 ** 27 # 128 MB transport.default_max_packet_size = 2 ** 19 # 512 KB transport.banner_timeout = timeout transport.start_client(timeout=timeout) # Host-key pin (TOFU) — mirror SSHClient behaviour against our pinned file. hk_path = _host_keys_path(dest["id"]) server_key = transport.get_remote_server_key() if os.path.isfile(hk_path): host_keys = paramiko.HostKeys() host_keys.load(hk_path) if not host_keys.check(dest["host"], server_key): transport.close() raise paramiko.BadHostKeyException(dest["host"], server_key, server_key) else: _ensure_cred_dir() host_keys = paramiko.HostKeys() host_keys.add(dest["host"], server_key.get_name(), server_key) host_keys.save(hk_path) if dest["auth_method"] == "password": with open(_password_path(dest["id"])) as f: transport.auth_password(dest["username"], f.read()) elif dest["auth_method"] == "key": try: pkey = paramiko.Ed25519Key.from_private_key_file(_key_path(dest["id"])) except paramiko.SSHException: pkey = paramiko.RSAKey.from_private_key_file(_key_path(dest["id"])) transport.auth_publickey(dest["username"], pkey) else: transport.close() raise ValueError(f"Unknown auth_method: {dest['auth_method']}") return transport @contextmanager def open_sftp(dest: dict, timeout: int = 15): """Open an SFTP session against the given destination dict. `dest` must contain: id, host, port, username, auth_method. Yields a paramiko.SFTPClient. Raises on any failure. """ transport = _open_transport(dest, timeout=timeout) try: sftp = paramiko.SFTPClient.from_transport(transport) try: yield sftp finally: try: sftp.close() except Exception: pass finally: try: transport.close() except Exception: pass def test_connection(dest: dict) -> tuple[bool, str]: ok, msg, _steps = test_connection_verbose(dest) return ok, msg def test_connection_verbose(dest: dict) -> tuple[bool, str, list[dict]]: """Run each handshake step in isolation and report exactly which one died.""" steps: list[dict] = [] transport = None sftp = None try: try: transport = _open_transport(dest, timeout=15) steps.append({ "step": "connect+auth", "ok": True, "detail": f"active={transport.is_active()} remote={transport.remote_version}", }) except paramiko.AuthenticationException as e: steps.append({"step": "connect+auth", "ok": False, "detail": f"auth failed: {e}"}) return False, "Authentication failed", steps except FileNotFoundError: steps.append({"step": "connect+auth", "ok": False, "detail": "no stored credentials"}) return False, "No stored credentials for this destination", steps except Exception as e: steps.append({"step": "connect+auth", "ok": False, "detail": f"{type(e).__name__}: {e}"}) return False, f"Connection failed: {e}", steps try: sftp = paramiko.SFTPClient.from_transport(transport) steps.append({"step": "open_sftp", "ok": True, "detail": "subsystem opened"}) except Exception as e: steps.append({"step": "open_sftp", "ok": False, "detail": f"{type(e).__name__}: {e}"}) return False, f"SFTP subsystem refused: {e}", steps try: entries = sftp.listdir("/") steps.append({"step": "listdir_/", "ok": True, "detail": f"entries: {entries[:10]}"}) except Exception as e: steps.append({"step": "listdir_/", "ok": False, "detail": f"{type(e).__name__}: {e}"}) return False, f"listdir / failed: {e}", steps try: sftp.stat(dest["base_path"]) steps.append({"step": "stat_base_path", "ok": True, "detail": dest["base_path"]}) except FileNotFoundError: steps.append({"step": "stat_base_path", "ok": False, "detail": "FileNotFoundError"}) return False, ( f"Base path does not exist (or not visible from this user): " f"{dest['base_path']}. Synology sometimes chroots SFTP users to " f"their home — try a path under /volume1/homes/{dest['username']}/ instead." ), steps except Exception as e: steps.append({"step": "stat_base_path", "ok": False, "detail": f"{type(e).__name__}: {e}"}) return False, f"stat {dest['base_path']} failed: {e}", steps probe = f"{dest['base_path'].rstrip('/')}/.dupfinder_probe" try: sftp.mkdir(probe) sftp.rmdir(probe) steps.append({"step": "write_probe", "ok": True, "detail": probe}) except Exception as e: steps.append({"step": "write_probe", "ok": False, "detail": f"{type(e).__name__}: {e}"}) return False, f"Connected, but {dest['base_path']} not writable: {e}", steps return True, "ok", steps finally: try: if sftp: sftp.close() except Exception: pass try: if transport: transport.close() except Exception: pass # ── Path helpers ──────────────────────────────────────────────────────────── def remote_path_for(source_path: str, dest: dict, photos_root: str = "/photos") -> str: """Compute the remote destination path for a given source file. If mirror_structure is true, preserves the path under photos_root. Otherwise, lands flat in base_path with the source basename. """ base = dest["base_path"].rstrip("/") if dest.get("mirror_structure", 1): rel = os.path.relpath(source_path, photos_root) # On Windows os.path.relpath uses backslashes; force forward rel = rel.replace("\\", "/") return f"{base}/{rel}" return f"{base}/{os.path.basename(source_path)}"