Files
sbackup/sbackup/handlers.py
T

625 lines
19 KiB
Python

"""命令处理器模块:SFTP 和 WebDAV 子命令的交互逻辑"""
import getpass
import logging
import os
import sys
from sbackup.i18n import t
logger = logging.getLogger(__name__)
def _validate_remote_filename(filename: str) -> str | None:
"""验证远程文件名安全性,返回净化后的文件名或 None(不安全时)
只允许纯文件名,拒绝路径分隔符和 .. 序列
"""
if not filename:
return None
# 拒绝 null 字节注入
if "\x00" in filename:
return None
# 拒绝路径分隔符
if "/" in filename or "\\" in filename:
return None
# 拒绝 .. 序列
if ".." in filename:
return None
# 拒绝绝对路径
if os.path.isabs(filename):
return None
# 只取 basename(防御性编程)
safe_name = os.path.basename(filename)
if not safe_name or safe_name in (".", ".."):
return None
return safe_name
def _resolve_sftp_auth(
key_file: str,
key_passphrase: str,
password: str,
*,
interactive: bool = False,
allow_password_fallback: bool = True,
) -> tuple[str, str, str]:
"""
解析 SFTP 认证凭据,返回 (key_file, key_passphrase, password)。
优先使用私钥认证,自动检测默认密钥,必要时交互式提示输入密码短语。
用户放弃输入密码短语时,若 allow_password_fallback=True 则回退到密码认证,
否则直接返回私钥路径(连接时再提示密码短语)。
:param key_file: 私钥文件路径(空字符串表示未指定)
:param key_passphrase: 私钥密码短语(空字符串表示未指定)
:param password: 密码(空字符串表示未指定)
:param interactive: 是否在缺少密码时交互式提示输入
:param allow_password_fallback: 是否允许在密码短语缺失时回退到密码认证
"""
from sbackup.sftp import SFTPClient
# 已有完整私钥凭据,直接返回
if key_file and key_passphrase:
return key_file, key_passphrase, ""
# 已有密码,直接返回
if password:
return "", "", password
# 尝试解析私钥
effective_key = key_file
if not effective_key:
# 未指定私钥,尝试默认位置
default_key = SFTPClient.try_default_key()
if default_key:
print(t("cmd.sftp.using_default_key", path=default_key))
effective_key = default_key
else:
# 无默认私钥,回退到密码
if interactive and not password:
password = getpass.getpass(t("cli.prompt.sftp.password") + " ")
return "", "", password
# 检测私钥是否需要密码短语
resolved = SFTPClient.resolve_key_passphrase(effective_key)
if resolved is None:
if allow_password_fallback:
# 用户放弃输入密码短语,回退到密码认证
if interactive and not password:
password = getpass.getpass(t("cli.prompt.sftp.password") + " ")
return "", "", password
# 不回退密码,返回私钥路径(使用时再提示密码短语)
return effective_key, "", ""
return effective_key, resolved, ""
def handle_sftp(args, config) -> int:
"""处理 sftp 子命令"""
from sbackup.config import save_sftp_config
from sbackup.sftp import SFTPClient, SFTPError
if args.sftp_action == "config":
# 交互式配置
host = args.host or input(t("cli.prompt.sftp.host") + " ")
port_str = (
str(args.port)
if args.port is not None
else input(t("cli.prompt.sftp.port") + " ")
)
try:
port = int(port_str)
if not (1 <= port <= 65535):
print(t("err.sftp.ssh", error=f"port {port} out of range"))
port = 22
except ValueError:
print(t("err.sftp.ssh", error=f"invalid port: {port_str}"))
port = 22
user = args.user or input(t("cli.prompt.sftp.user") + " ")
key_file_input = args.key_file or input(t("cli.prompt.sftp.key_file") + " ")
# 警告:通过 CLI 传递密码会暴露在进程列表中
if args.password:
print(
t("warn.password_in_cli", arg="--password"),
file=sys.stderr,
)
if args.key_passphrase:
print(
t("warn.password_in_cli", arg="--key-passphrase"),
file=sys.stderr,
)
key_file, key_passphrase, password = _resolve_sftp_auth(
key_file_input,
args.key_passphrase or "",
args.password or "",
interactive=True,
allow_password_fallback=False,
)
remote_path = (
args.remote_path or input(t("cli.prompt.sftp.remote_path") + " ") or "/"
)
save_sftp_config(
host,
port,
user,
password,
remote_path,
key_file=key_file,
key_passphrase=key_passphrase,
)
print(t("cmd.sftp.config_saved"))
return 0
elif args.sftp_action == "test":
if not config.sftp_enabled or not config.sftp_host:
print(t("err.sftp.not_configured"))
return 1
key_file, key_passphrase, password = _resolve_sftp_auth(
config.sftp_key_file,
config.sftp_key_passphrase,
config.sftp_password,
)
if not key_file and not password:
print(t("cmd.sftp.no_default_key"))
return 1
print(t("cmd.sftp.testing", host=config.sftp_host))
try:
client = SFTPClient(
config.sftp_host,
config.sftp_port,
config.sftp_user,
password,
key_file,
key_passphrase,
)
client.connect()
client.disconnect()
print(t("cmd.sftp.test_ok"))
return 0
except SFTPError as e:
print(str(e))
return 1
print(t("cli.help.sftp.action"))
return 1
def handle_webdav(args, config) -> int:
"""处理 webdav 子命令"""
from sbackup.config import save_webdav_config
from sbackup.webdav import WebDAVClient, WebDAVError
if args.webdav_action == "config":
# 交互式配置
url = args.url or input(t("cli.prompt.webdav.url") + " ")
user = args.user or input(t("cli.prompt.webdav.user") + " ")
# 警告:通过 CLI 传递密码会暴露在进程列表中
if args.password:
print(
t("warn.password_in_cli", arg="--password"),
file=sys.stderr,
)
password = args.password or getpass.getpass(
t("cli.prompt.webdav.password") + " "
)
remote_path = (
args.remote_path or input(t("cli.prompt.webdav.remote_path") + " ") or "/"
)
save_webdav_config(url, user, password, remote_path)
print(t("cmd.webdav.config_saved"))
return 0
elif args.webdav_action == "test":
if not config.webdav_enabled or not config.webdav_url:
print(t("err.webdav.not_configured"))
return 1
print(t("cmd.webdav.testing", url=config.webdav_url))
try:
client = WebDAVClient(
config.webdav_url,
config.webdav_user,
config.webdav_password,
)
client.connect()
print(t("cmd.webdav.test_ok"))
return 0
except WebDAVError as e:
print(str(e))
return 1
print(t("cli.help.webdav.action"))
return 1
def _format_file_size(size_bytes: int) -> str:
"""格式化文件大小为人类可读格式"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.2f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def handle_remote(args, config) -> int:
"""处理 remote 子命令"""
use_sftp = getattr(args, "sftp", False)
use_webdav = getattr(args, "webdav", False)
# 如果两个都没指定,默认使用 sftp
if not use_sftp and not use_webdav:
use_sftp = True
if use_sftp:
return _handle_remote_sftp(args, config)
elif use_webdav:
return _handle_remote_webdav(args, config)
else:
print(t("cmd.remote.select_protocol"))
return 1
def _handle_remote_sftp(args, config) -> int:
"""处理 SFTP 远程文件管理"""
import time as _time_mod
from sbackup.sftp import SFTPClient, SFTPError
if not config.sftp_enabled or not config.sftp_host:
print(t("err.sftp.not_configured"))
return 1
key_file, key_passphrase, password = _resolve_sftp_auth(
config.sftp_key_file,
config.sftp_key_passphrase,
config.sftp_password,
)
if not key_file and not password:
print(t("cmd.sftp.no_default_key"))
return 1
try:
with SFTPClient(
config.sftp_host,
config.sftp_port,
config.sftp_user,
password,
key_file,
key_passphrase,
) as client:
if args.remote_action == "list":
remote_path = getattr(args, "path", None) or config.sftp_remote_path
files = client.list_remote_files(remote_path)
if not files:
print(t("cmd.remote.empty", path=remote_path))
return 0
print(t("cmd.remote.list_header", path=remote_path, count=len(files)))
for f in files:
mtime_str = (
_time_mod.strftime(
"%Y-%m-%d %H:%M:%S", _time_mod.localtime(f["mtime"])
)
if f["mtime"]
else "N/A"
)
size_str = _format_file_size(f["size"])
print(f" {f['name']:40s} {size_str:>10s} {mtime_str}")
return 0
elif args.remote_action == "rm":
safe_name = _validate_remote_filename(args.filename)
if not safe_name:
print(t("err.invalid_filename", filename=args.filename))
return 1
remote_path = config.sftp_remote_path.rstrip("/") + "/" + safe_name
client.delete_remote_file(remote_path)
print(t("cmd.remote.deleted", path=remote_path))
return 0
except SFTPError as e:
print(str(e))
return 1
print(t("cli.help.remote.action"))
return 1
def _handle_remote_webdav(args, config) -> int:
"""处理 WebDAV 远程文件管理"""
import time as _time_mod
from sbackup.webdav import WebDAVClient, WebDAVError
if not config.webdav_enabled or not config.webdav_url:
print(t("err.webdav.not_configured"))
return 1
try:
client = WebDAVClient(
config.webdav_url,
config.webdav_user,
config.webdav_password,
)
client.connect()
if args.remote_action == "list":
remote_path = getattr(args, "path", None) or config.webdav_remote_path
files = client.list_remote_files(remote_path)
if not files:
print(t("cmd.remote.empty", path=remote_path))
return 0
print(t("cmd.remote.list_header", path=remote_path, count=len(files)))
for f in files:
mtime_str = (
_time_mod.strftime(
"%Y-%m-%d %H:%M:%S", _time_mod.localtime(f["mtime"])
)
if f["mtime"]
else "N/A"
)
size_str = _format_file_size(f["size"])
print(f" {f['name']:40s} {size_str:>10s} {mtime_str}")
return 0
elif args.remote_action == "rm":
safe_name = _validate_remote_filename(args.filename)
if not safe_name:
print(t("err.invalid_filename", filename=args.filename))
return 1
remote_path = config.webdav_remote_path.rstrip("/") + "/" + safe_name
client.delete_remote_file(remote_path)
print(t("cmd.remote.deleted", path=remote_path))
return 0
except WebDAVError as e:
print(str(e))
return 1
print(t("cli.help.remote.action"))
return 1
def handle_schedule(args, config) -> int:
"""处理 schedule 子命令"""
if args.schedule_action == "export":
return _export_schedule(args, config)
elif args.schedule_action == "install":
return _handle_schedule_install(args, config)
print(t("cli.help.schedule.action"))
return 1
def _export_schedule(args, config) -> int:
"""导出定时调度配置"""
fmt = args.type
interval = args.interval
output = args.output or ""
if fmt == "systemd":
content = _generate_systemd(interval)
elif fmt == "crontab":
content = _generate_crontab(interval)
elif fmt == "schtasks":
content = _generate_schtasks(interval)
else:
print(t("cmd.schedule.unknown_type", type=fmt))
return 1
if output:
with open(output, "w", encoding="utf-8") as f:
f.write(content)
print(t("cmd.schedule.exported", path=output, type=fmt))
else:
print(content)
return 0
def _generate_systemd(interval_minutes: int) -> str:
"""生成 systemd service + timer 配置"""
import shutil
sbackup_path = shutil.which("sbackup") or "/usr/local/bin/sbackup"
service = f"""[Unit]
Description=Sbackup incremental backup
After=network.target
[Service]
Type=oneshot
ExecStart={sbackup_path} save
"""
timer = f"""[Unit]
Description=Run sbackup every {interval_minutes} minutes
[Timer]
OnBootSec=5min
OnUnitActiveSec={interval_minutes}min
Persistent=true
[Install]
WantedBy=timers.target
"""
return (
"# === sbackup.service ===\n"
+ service
+ "\n# === sbackup.timer ===\n"
+ timer
+ "\n# 安装方法:\n"
+ "# sudo cp sbackup.service sbackup.timer /etc/systemd/system/\n"
+ "# sudo systemctl daemon-reload\n"
+ "# sudo systemctl enable --now sbackup.timer\n"
)
def _generate_crontab(interval_minutes: int) -> str:
"""生成 crontab 条目"""
import shutil
sbackup_path = shutil.which("sbackup") or "/usr/local/bin/sbackup"
if interval_minutes < 60:
cron_expr = f"*/{interval_minutes} * * * *"
elif interval_minutes < 1440:
hours = interval_minutes // 60
cron_expr = f"0 */{hours} * * *"
else:
days = interval_minutes // 1440
cron_expr = f"0 0 */{days} * *"
return (
f"# sbackup 定时备份 (每 {interval_minutes} 分钟)\n"
f"{cron_expr} {sbackup_path} save\n"
f"\n# 安装方法:\n"
f"# crontab -e 然后粘贴上面的行\n"
)
def _generate_schtasks(interval_minutes: int) -> str:
"""生成 Windows 计划任务 XML 和 schtasks 命令"""
import shutil
sbackup_path = shutil.which("sbackup") or "sbackup"
# 计算重复间隔(PT{H}H{M}M 格式)
hours = interval_minutes // 60
minutes = interval_minutes % 60
if hours > 0 and minutes > 0:
repetition = f"PT{hours}H{minutes}M"
elif hours > 0:
repetition = f"PT{hours}H"
else:
repetition = f"PT{minutes}M"
xml = f"""<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Sbackup incremental backup (every {interval_minutes} min)</Description>
</RegistrationInfo>
<Triggers>
<TimeTrigger>
<Repetition>
<Interval>{repetition}</Interval>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
<StartBoundary>2026-01-01T00:00:00</StartBoundary>
<Enabled>true</Enabled>
</TimeTrigger>
</Triggers>
<Actions>
<Exec>
<Command>{sbackup_path}</Command>
<Arguments>save</Arguments>
</Exec>
</Actions>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>true</StartWhenAvailable>
</Settings>
</Task>"""
cmd = f'schtasks /create /tn "SbackupBackup" /tr "{sbackup_path} save" /sc MINUTE /mo {interval_minutes} /f'
return (
f"# === schtasks 命令(推荐) ===\n"
f"# 以管理员权限运行以下命令:\n"
f"{cmd}\n"
f"\n# === 或使用 XML 导入 ===\n"
f"# 保存为 sbackup_task.xml,然后运行:\n"
f'# schtasks /create /tn "SbackupBackup" /xml sbackup_task.xml /f\n'
f"\n{xml}"
)
def _handle_schedule_install(args, config) -> int:
"""生成并打印安装系统调度服务的命令"""
sbackup_cmd = f"{sys.executable} -m sbackup"
if args.type == "systemd":
service_content = f"""[Unit]
Description=sbackup periodic backup
After=network.target
[Service]
Type=oneshot
ExecStart={sbackup_cmd} save
User={args.user or os.environ.get("USER", "root")}
[Install]
WantedBy=multi-user.target
"""
timer_content = f"""[Unit]
Description=sbackup backup timer
[Timer]
OnCalendar=*:0/{args.interval}
Persistent=true
[Install]
WantedBy=timers.target
"""
print(t("cmd.schedule.install.systemd"))
print()
print("# sbackup.service:")
print(service_content)
print("# sbackup.timer:")
print(timer_content)
print("# To install:")
print(" cat > /etc/systemd/system/sbackup.service << 'EOF'")
print(service_content.replace("'", "'\\''"))
print(" EOF")
print(" cat > /etc/systemd/system/sbackup.timer << 'EOF'")
print(timer_content.replace("'", "'\\''"))
print(" EOF")
print(" systemctl daemon-reload && systemctl enable --now sbackup.timer")
elif args.type == "schtasks":
cmdline = f"{sbackup_cmd} save"
print(
t(
"cmd.schedule.install.schtasks_cmd",
interval=args.interval,
cmdline=cmdline,
)
)
elif args.type == "launchd":
plist = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.sbackup.backup</string>
<key>ProgramArguments</key>
<array>
<string>{sys.executable}</string>
<string>-m</string>
<string>sbackup</string>
<string>save</string>
</array>
<key>StartInterval</key>
<integer>{args.interval * 60}</integer>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>"""
print(t("cmd.schedule.install.launchd"))
print()
print(plist)
print("# Save as ~/Library/LaunchAgents/com.sbackup.backup.plist")
print(
"# Then run: launchctl load ~/Library/LaunchAgents/com.sbackup.backup.plist"
)
return 0