Stage 2 #1: SFTP destinations CRUD + connection test

Foundation for the move/quarantine pipeline. Lets users register one or
more remote SFTP destinations through the API, store credentials at rest
under /data/sftp/{id}.{password|key} (mode 600), and verify connectivity
+ write access via a test endpoint.

Endpoints:
  GET    /api/sftp/destinations
  POST   /api/sftp/destinations             — create
  PUT    /api/sftp/destinations/{id}        — update
  DELETE /api/sftp/destinations/{id}
  POST   /api/sftp/destinations/{id}/test   — connect, stat base_path, mkdir probe
  POST   /api/sftp/keypair                  — generate ED25519 keypair

Host keys pinned per-destination on first connect (TOFU); subsequent
mismatches are rejected. paramiko added to requirements.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos
2026-04-26 20:04:42 -04:00
parent 8b0fee0055
commit 7436b23db3
5 changed files with 386 additions and 1 deletions

View File

@@ -20,6 +20,7 @@ from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import scanner as sc
import sftp as sftp_mod
app = FastAPI(title="Duplicate Finder")
@@ -882,3 +883,171 @@ def export_csv():
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=dup-finder-export.csv"},
)
# ── SFTP destinations ────────────────────────────────────────────────────────
class SFTPDestBody(BaseModel):
name: str
host: str
port: int = 22
username: str
auth_method: str # 'password' | 'key'
base_path: str
mirror_structure: bool = True
# Either password (for password auth) or private_key (for key auth).
# Optional on update — omit to leave existing credential untouched.
password: Optional[str] = None
private_key: Optional[str] = None
def _dest_row_to_dict(row) -> dict:
return {
"id": row["id"],
"name": row["name"],
"host": row["host"],
"port": row["port"],
"username": row["username"],
"auth_method": row["auth_method"],
"base_path": row["base_path"],
"mirror_structure": bool(row["mirror_structure"]),
"enabled": bool(row["enabled"]),
"created_at": row["created_at"],
"last_tested_at": row["last_tested_at"],
"last_test_result": row["last_test_result"],
"has_credentials": sftp_mod.has_credentials(row["id"], row["auth_method"]),
}
@app.get("/api/sftp/destinations")
def list_destinations():
con = get_db()
cur = con.cursor()
cur.execute("SELECT * FROM sftp_destinations ORDER BY name")
out = [_dest_row_to_dict(r) for r in cur.fetchall()]
con.close()
return out
@app.post("/api/sftp/destinations", status_code=201)
def create_destination(body: SFTPDestBody):
if body.auth_method not in ("password", "key"):
raise HTTPException(400, "auth_method must be 'password' or 'key'")
if body.auth_method == "password" and not body.password:
raise HTTPException(400, "password required for password auth")
if body.auth_method == "key" and not body.private_key:
raise HTTPException(400, "private_key required for key auth")
con = get_db()
cur = con.cursor()
try:
cur.execute("""
INSERT INTO sftp_destinations
(name, host, port, username, auth_method, base_path, mirror_structure)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (body.name, body.host, body.port, body.username,
body.auth_method, body.base_path, 1 if body.mirror_structure else 0))
dest_id = cur.lastrowid
con.commit()
except sqlite3.IntegrityError:
con.close()
raise HTTPException(409, f"Destination name already in use: {body.name}")
if body.auth_method == "password":
sftp_mod.write_password(dest_id, body.password)
else:
sftp_mod.write_private_key(dest_id, body.private_key)
cur.execute("SELECT * FROM sftp_destinations WHERE id=?", (dest_id,))
out = _dest_row_to_dict(cur.fetchone())
con.close()
return out
@app.put("/api/sftp/destinations/{dest_id}")
def update_destination(dest_id: int, body: SFTPDestBody):
con = get_db()
cur = con.cursor()
cur.execute("SELECT * FROM sftp_destinations WHERE id=?", (dest_id,))
row = cur.fetchone()
if not row:
con.close()
raise HTTPException(404, "Destination not found")
cur.execute("""
UPDATE sftp_destinations
SET name=?, host=?, port=?, username=?, auth_method=?,
base_path=?, mirror_structure=?
WHERE id=?
""", (body.name, body.host, body.port, body.username,
body.auth_method, body.base_path,
1 if body.mirror_structure else 0, dest_id))
# If auth method changed, drop old creds
if row["auth_method"] != body.auth_method:
sftp_mod.delete_credentials(dest_id)
if body.auth_method == "password" and body.password:
sftp_mod.write_password(dest_id, body.password)
elif body.auth_method == "key" and body.private_key:
sftp_mod.write_private_key(dest_id, body.private_key)
con.commit()
cur.execute("SELECT * FROM sftp_destinations WHERE id=?", (dest_id,))
out = _dest_row_to_dict(cur.fetchone())
con.close()
return out
@app.delete("/api/sftp/destinations/{dest_id}", status_code=204)
def delete_destination(dest_id: int):
con = get_db()
cur = con.cursor()
cur.execute("DELETE FROM sftp_destinations WHERE id=?", (dest_id,))
if cur.rowcount == 0:
con.close()
raise HTTPException(404, "Destination not found")
con.commit()
con.close()
sftp_mod.delete_credentials(dest_id)
return Response(status_code=204)
@app.post("/api/sftp/destinations/{dest_id}/test")
def test_destination(dest_id: int):
con = get_db()
cur = con.cursor()
cur.execute("SELECT * FROM sftp_destinations WHERE id=?", (dest_id,))
row = cur.fetchone()
if not row:
con.close()
raise HTTPException(404, "Destination not found")
dest = _dest_row_to_dict(row)
if not dest["has_credentials"]:
con.close()
raise HTTPException(400, "No credentials stored for this destination")
ok, message = sftp_mod.test_connection(dest)
cur.execute("""
UPDATE sftp_destinations
SET last_tested_at=CURRENT_TIMESTAMP, last_test_result=?
WHERE id=?
""", ("ok" if ok else message, dest_id))
con.commit()
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}
@app.post("/api/sftp/keypair")
def generate_keypair():
"""Generate a fresh ED25519 keypair. Returns the private + public halves;
the caller is expected to paste the private key into a destination's
private_key field on create/update."""
private_pem, public_openssh, fingerprint = sftp_mod.generate_keypair()
return {
"private_key": private_pem,
"public_key": public_openssh,
"fingerprint": fingerprint,
}