paramiko's SSHClient.open_sftp() allocates an exec channel before the SFTP subsystem request, which Synology DSM closes immediately with 'Channel closed'. Manual sftp(1) and WinSCP avoid this by going straight to the SFTP subsystem on a fresh channel. Replaced SSHClient with direct paramiko.Transport + SFTPClient.from_transport, matching the OpenSSH/WinSCP flow. Larger flow-control windows (128 MB) too since Synology has been observed to bail mid-handshake with the default 1 MB. test_connection_verbose now reports per-step status (connect+auth, open_sftp, listdir /, stat base_path, write probe). API returns the steps array so the UI can show exactly which step failed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
258 lines
9.7 KiB
Python
258 lines
9.7 KiB
Python
"""
|
|
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)}"
|