Files
zTools2/app/services/sftp_server.py
zikai 80b96d236f init: 从 /root/zikai 根目录迁入
把原本散落在 /root/zikai 的 FastAPI 服务整理到 server/ 子目录,
开启独立 git 与 venv。运行时数据(config.yaml / keys / uploads)
按 .gitignore 留在工作目录但不入仓。
2026-06-23 16:17:31 +00:00

162 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""嵌入式 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()