Files
zTools2/app/services/sftp_server.py
zikai 9af28f41b4 refactor: 清理死代码/提前失败/日志/高内聚低耦合
死代码移除:
- whiteboard_hub.py: 移除未引用的 reset_hub 单例重置函数
- tunnel_service.py: 移除未引用的 is_port_allowed (逻辑已在 sftp_server 内联)
- tunnel_session_dao.py: 移除未引用的 get_active_by_port
- pdf_job_dao.py: 移除未用 datetime 导入
- pdf_converter.py: 移除未用 shutil 导入
- pdf_service.py: 移除未用 PdfSubmitResponse 导入 + _do_convert 内未用 hashlib 导入
- upload_html.py: 移除未用 escape 导入 (JS 侧自有 escapeHtml)
- pdf_controller.py: 移除 _resolve_cookie 内未用 cfg 局部变量

提前失败/分层修复:
- database.py init_db_schema: 建表后用 inspector 校验既有表列与模型一致,
  缺列即抛 RuntimeError (fail-fast on schema drift), 避免运行期才暴露
- whiteboard_dao.get_or_create: 仅 IntegrityError 才回滚重读, 其他异常向上抛
  (原 except Exception 会掩盖 schema/连接等真实故障)
- pdf_service.admin_delete/_safe_delete_file: 改用 PdfJobDAO.delete /
  UploadedFileDAO.delete, 不再直接操作 job_dao.db / file_dao.db (修复分层契约:
  DAO 头注释声明 service 不直接操作 session)
- PdfJobDAO 新增 delete(job) 方法

日志补全 (8 处 silent catch):
- whiteboard_hub.py disconnect/close_board 关闭 ws: logger.debug
- whiteboard_controller _safe_send/_safe_close: logger.debug
- sftp_server _close_tunnel_dao/读用户名: logger.debug
- sftp_server validate_public_key: logger.warning (auth 路径, 避免静默失败)

文档:
- 新增 docs/routes.md, docs/configuration.md, docs/error-handling.md
- README.md 精简为简介/结构/外部依赖/apache2 配置/Ubuntu 安装/docs 链接
2026-07-28 11:34:35 +08:00

254 lines
9.0 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/ 目录;同时承载反向隧道的 SSH 转发。
启动方式:
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 as exc: # pragma: no cover
self._username = "unknown"
logger.debug("读取 SFTP 会话用户名失败: %s", exc)
logger.info("SFTP 会话开始 user=%s chroot=%s", self._username, upload_root)
def exit(self) -> None:
logger.info("SFTP 会话结束 user=%s", self._username)
def _tunnel_dao():
"""惰性构造一个 TunnelSessionDAO避免 import 时的副作用)。"""
from ..database import get_session_local
from ..dao.tunnel_session_dao import TunnelSessionDAO
return TunnelSessionDAO(get_session_local()())
def _close_tunnel_dao(dao) -> None:
try:
dao.db.close()
except Exception as exc: # pragma: no cover
logger.debug("关闭隧道 DAO 会话失败: %s", exc)
class ZikaiSSHServer(asyncssh.SSHServer):
"""支持密码bcrypt与公钥两种鉴权允许反向隧道 user 的 remote forwarding。"""
def __init__(self, settings, authorized_keys: asyncssh.SSHAuthorizedKeys | None) -> None:
self._settings = settings
self._authorized_keys = authorized_keys
self._conn: asyncssh.SSHServerConnection | None = None
self._username: str | None = None # 认证成功后填入
self._peer_ip: str = ""
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None: # type: ignore[override]
self._conn = conn
peer = conn.get_extra_info("peername")
self._peer_ip = peer[0] if isinstance(peer, tuple) and peer else ""
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 = self._find_sftp_user(username) or self._find_tunnel_user(username)
if user is None or not getattr(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
if ok:
self._username = username
logger.info("SSH 密码鉴权 %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 self._find_sftp_user(username) is None and self._find_tunnel_user(username) is None:
return False
addr = self._peer_ip
try:
# asyncssh 命中返回 dict可能为空未命中返回 None
result = self._authorized_keys.validate(key, client_host=addr, client_addr=addr)
except Exception as exc: # pragma: no cover
logger.warning("公钥校验异常 user=%s: %s", username, exc)
result = None
ok = result is not None
if ok:
self._username = username
logger.info("SSH 公钥鉴权 %s user=%s", "OK" if ok else "FAIL", username)
return ok
# 反向隧道remote port-forward 请求
def server_requested(self, listen_host: str, listen_port: int) -> bool:
"""客户端请求在本地server 侧)绑端口做反向转发时回调。
仅允许已认证的 tunnel user 绑定其配置中预定的 tunnel_port记一条 active
隧道会话到 DB。其他情况拒绝。
"""
if not self._settings.tunnel.enabled:
logger.warning("拒绝 remote forwardtunnel 未启用 user=%s", self._username)
return False
if not self._username:
logger.warning("拒绝 remote forward未认证")
return False
tunnel_user = self._find_tunnel_user(self._username)
if tunnel_user is None:
logger.warning("拒绝 remote forward%s 不是 tunnel user", self._username)
return False
if listen_port != tunnel_user.tunnel_port:
logger.warning(
"拒绝 remote forwarduser=%s 端口 %d 不等于配置 %d",
self._username, listen_port, tunnel_user.tunnel_port,
)
return False
dao = _tunnel_dao()
try:
from ..services.tunnel_service import TunnelService
TunnelService(dao).register(
user_name=self._username,
user_ip=self._peer_ip,
tunnel_port=listen_port,
local_port=tunnel_user.local_port,
)
except Exception as exc: # pragma: no cover
_close_tunnel_dao(dao)
logger.error("登记隧道会话失败:%s", exc)
return False
_close_tunnel_dao(dao)
logger.info(
"允许 remote forward user=%s listen=%s:%d -> local_port=%d",
self._username, listen_host, listen_port, tunnel_user.local_port,
)
return True
def connection_lost(self, exc: Exception | None) -> None: # type: ignore[override]
"""SSH 连接断开时清理该 user 的活跃隧道会话。"""
if self._username and self._settings.tunnel.enabled:
dao = _tunnel_dao()
try:
from ..services.tunnel_service import TunnelService
TunnelService(dao).close(self._username)
except Exception as e: # pragma: no cover
logger.warning("清理隧道会话失败 user=%s: %s", self._username, e)
_close_tunnel_dao(dao)
if exc:
logger.info("SSH 连接异常断开 user=%s: %s", self._username, exc)
else:
logger.info("SSH 连接关闭 user=%s", self._username)
# 内部
def _find_sftp_user(self, username: str):
return next((u for u in self._settings.sftp.users if u.username == username), None)
def _find_tunnel_user(self, username: str):
return self._settings.tunnel.find_user(username)
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)
# SFTP 客户端的暂存目录chroot 内)。
(upload_root / "incoming").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()