init: 从 /root/zikai 根目录迁入

把原本散落在 /root/zikai 的 FastAPI 服务整理到 server/ 子目录,
开启独立 git 与 venv。运行时数据(config.yaml / keys / uploads)
按 .gitignore 留在工作目录但不入仓。
This commit is contained in:
zikai
2026-06-23 16:17:31 +00:00
commit 80b96d236f
31 changed files with 1491 additions and 0 deletions

161
app/services/sftp_server.py Normal file
View File

@@ -0,0 +1,161 @@
"""嵌入式 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()