"""嵌入式 SFTP 服务,与 HTTP API 共用 uploads/ 目录。 启动方式: python -m app.services.sftp_server """ from __future__ import annotations import asyncio import logging import os import sys from pathlib import Path import asyncssh import bcrypt from ..config import PROJECT_ROOT, get_settings logger = logging.getLogger("sftp") class ZikaiSFTPServer(asyncssh.SFTPServer): """会话期把客户端 chroot 到 upload_root。""" def __init__(self, chan, *, upload_root: Path) -> None: super().__init__(chan, chroot=str(upload_root).encode()) try: self._username = chan.get_extra_info("username") or "unknown" except Exception: # pragma: no cover self._username = "unknown" logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root) def exit(self) -> None: logger.info("SFTP 会话结束 user=%s", self._username) class ZikaiSSHServer(asyncssh.SSHServer): """支持密码(bcrypt)与公钥两种鉴权。""" def __init__(self, settings, authorized_keys: asyncssh.SSHAuthorizedKeys | None) -> None: self._settings = settings self._authorized_keys = authorized_keys self._conn: asyncssh.SSHServerConnection | None = None def connection_made(self, conn: asyncssh.SSHServerConnection) -> None: # type: ignore[override] self._conn = conn def begin_auth(self, username: str) -> bool: # 始终要求鉴权;用户不存在时所有方法会失败 → 干净的 permission denied return True # 密码 def password_auth_supported(self) -> bool: return True def validate_password(self, username: str, password: str) -> bool: user = next((u for u in self._settings.sftp.users if u.username == username), None) if user is None or not user.password_hash or user.password_hash == "CHANGE_ME_BCRYPT_HASH": return False try: ok = bcrypt.checkpw(password.encode(), user.password_hash.encode()) except (ValueError, TypeError): ok = False logger.info("SFTP 密码鉴权 %s user=%s", "OK" if ok else "FAIL", username) return ok # 公钥 def public_key_auth_supported(self) -> bool: return self._authorized_keys is not None def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: if self._authorized_keys is None: return False if not any(u.username == username for u in self._settings.sftp.users): return False addr = "" if self._conn: peer = self._conn.get_extra_info("peername") addr = peer[0] if isinstance(peer, tuple) and peer else "" try: # asyncssh 命中返回 dict(可能为空),未命中返回 None result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr) except Exception: result = None ok = result is not None logger.info("SFTP 公钥鉴权 %s user=%s", "OK" if ok else "FAIL", username) return ok def _load_authorized_keys(path: Path) -> asyncssh.SSHAuthorizedKeys | None: if not path.exists() or path.stat().st_size == 0: return None try: return asyncssh.read_authorized_keys(str(path)) except Exception as exc: # pragma: no cover logger.warning("解析 %s 失败:%s", path, exc) return None def _ensure_host_key(path: Path) -> None: if path.exists(): return path.parent.mkdir(parents=True, exist_ok=True) logger.info("生成 ed25519 SFTP 主机密钥:%s", path) key = asyncssh.generate_private_key("ssh-ed25519") key.write_private_key(str(path)) key.write_public_key(str(path) + ".pub") os.chmod(path, 0o600) async def _run() -> None: settings = get_settings() if not settings.sftp.enabled: logger.info("SFTP 在配置中已禁用,进程退出。") return upload_root = settings.resolved_upload_dir() upload_root.mkdir(parents=True, exist_ok=True) host_key_path = (PROJECT_ROOT / settings.sftp.host_key_path).resolve() _ensure_host_key(host_key_path) authorized_keys = _load_authorized_keys( (PROJECT_ROOT / settings.sftp.authorized_keys_path).resolve() ) def sftp_factory(chan) -> ZikaiSFTPServer: return ZikaiSFTPServer(chan, upload_root=upload_root) logger.info( "启动 SFTP 服务 %s:%d (root=%s)", settings.sftp.host, settings.sftp.port, upload_root, ) await asyncssh.create_server( lambda: ZikaiSSHServer(settings, authorized_keys), settings.sftp.host, settings.sftp.port, server_host_keys=[str(host_key_path)], sftp_factory=sftp_factory, allow_scp=False, ) await asyncio.Event().wait() def main() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", stream=sys.stdout, ) try: asyncio.run(_run()) except (KeyboardInterrupt, SystemExit): logger.info("SFTP 服务退出。") if __name__ == "__main__": main()