SFTP: switch to Transport-based connection (fixes Synology 'Channel closed')

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>
This commit is contained in:
Carlos
2026-04-26 21:43:56 -04:00
parent a7b023c193
commit 293355b724
3 changed files with 132 additions and 75 deletions

View File

@@ -1027,7 +1027,7 @@ def test_destination(dest_id: int):
con.close()
raise HTTPException(400, "No credentials stored for this destination")
ok, message = sftp_mod.test_connection(dest)
ok, message, steps = sftp_mod.test_connection_verbose(dest)
cur.execute("""
UPDATE sftp_destinations
SET last_tested_at=CURRENT_TIMESTAMP, last_test_result=?
@@ -1037,7 +1037,7 @@ def test_destination(dest_id: int):
cur.execute("SELECT * FROM sftp_destinations WHERE id=?", (dest_id,))
out = _dest_row_to_dict(cur.fetchone())
con.close()
return {"ok": ok, "message": message, "destination": out}
return {"ok": ok, "message": message, "steps": steps, "destination": out}
@app.post("/api/sftp/keypair")