"""将 lzwlab.cn 文件快传部署到远程服务器(幂等,可重复执行)。 用法(凭据从 Authentication.md 解析;应用登录密码通过环境变量传入,不落盘): LZWLAB_APP_PASSWORD='你的应用登录密码' python deploy_transfer.py --username admin 服务器端安装: - apt 安装 python3-flask、gunicorn - 应用代码安装到 /opt/lzwlab-transfer(root 所有) - 运行用户 lzwlab(systemd 服务,监听 127.0.0.1:8090) - 数据目录 /var/lib/lzwlab-transfer,配置 /etc/lzwlab-transfer/config.json(密码仅存 scrypt 哈希) - systemd 定时器每 10 分钟清理过期文件 - 更新 nginx 反代配置与首页链接 """ import argparse import json import os import re import sys import time import paramiko ROOT = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(ROOT, "transfer")) import auth # noqa: E402 REMOTE_TMP = "/tmp/lzwlab-transfer-deploy" APP_DIR = "/opt/lzwlab-transfer" DATA_DIR = "/var/lib/lzwlab-transfer" CONFIG_PATH = "/etc/lzwlab-transfer/config.json" LOG_DIR = "/var/log/lzwlab-transfer" SERVICE_USER = "lzwlab" PORT = 8090 LOCAL_FILES = [ ("transfer/app.py", "app.py"), ("transfer/auth.py", "auth.py"), ("transfer/storage.py", "storage.py"), ("transfer/cleanup.py", "cleanup.py"), ("transfer/templates/index.html", "templates/index.html"), ] SYSTEMD_FILES = [ ("transfer/systemd/lzwlab-transfer.service", "lzwlab-transfer.service"), ("transfer/systemd/lzwlab-transfer-cleanup.service", "lzwlab-transfer-cleanup.service"), ("transfer/systemd/lzwlab-transfer-cleanup.timer", "lzwlab-transfer-cleanup.timer"), ] def parse_auth() -> tuple[str, str, str]: text = open(os.path.join(ROOT, "Authentication.md"), encoding="utf-8").read() def find(label: str) -> str: m = re.search(label + r"\s*[::]\s*(\S+)", text) if not m: sys.exit("无法解析 Authentication.md 中的 " + label) return m.group(1) return find("IP地址"), find("用户名"), find("密码") def connect(ip: str, user: str, pw: str) -> paramiko.SSHClient: cli = paramiko.SSHClient() cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) cli.connect(ip, port=22, username=user, password=pw, timeout=20, look_for_keys=False, allow_agent=False, banner_timeout=30) return cli def run(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> tuple[int, str, str]: print(" $ " + cmd) _in, out, err = ssh.exec_command(cmd, timeout=timeout) out_s = out.read().decode("utf-8", "replace") err_s = err.read().decode("utf-8", "replace") code = out.channel.recv_exit_status() if out_s.strip(): print(out_s.strip()) if err_s.strip(): print("[stderr]", err_s.strip()) return code, out_s, err_s def must(ssh: paramiko.SSHClient, cmd: str, timeout: int = 600) -> str: code, out, err = run(ssh, cmd, timeout) if code != 0: sys.exit("命令失败(exit=%d):%s" % (code, cmd)) return out def sftp_mkdir_p(sftp: paramiko.SFTPClient, remote_dir: str) -> None: try: sftp.stat(remote_dir) except IOError: parent = os.path.dirname(remote_dir.rstrip("/")) if parent and parent != remote_dir: sftp_mkdir_p(sftp, parent) sftp.mkdir(remote_dir) def upload(ssh: paramiko.SSHClient, local: str, remote: str) -> None: print(" [upload] %s -> %s" % (local, remote)) sftp = ssh.open_sftp() try: sftp_mkdir_p(sftp, os.path.dirname(remote)) sftp.put(os.path.join(ROOT, local), remote) finally: sftp.close() def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--username", default="admin", help="应用登录用户名(默认 admin)") ap.add_argument("--password-env", default="LZWLAB_APP_PASSWORD", help="应用登录密码所在的环境变量名") ap.add_argument("--skip-apt", action="store_true", help="跳过 apt 安装依赖") args = ap.parse_args() password = os.environ.get(args.password_env, "") if len(password) < 8: sys.exit("请通过环境变量 %s 提供至少 8 位的应用登录密码" % args.password_env) ip, ssh_user, ssh_pw = parse_auth() print("目标服务器:%s@%s" % (ssh_user, ip)) ssh = connect(ip, ssh_user, ssh_pw) try: # 1. 依赖 if not args.skip_apt: code, _, err = run(ssh, "sudo -n env DEBIAN_FRONTEND=noninteractive " "apt-get install -y python3-flask gunicorn", timeout=900) if code != 0: print("直接安装失败,尝试先 apt-get update …") must(ssh, "sudo -n apt-get update -qq", timeout=900) must(ssh, "sudo -n env DEBIAN_FRONTEND=noninteractive " "apt-get install -y python3-flask gunicorn", timeout=900) # 2. 目录、运行用户与数据目录 must(ssh, "sudo -n mkdir -p %s %s/templates %s/files %s /etc/lzwlab-transfer" % (APP_DIR, APP_DIR, DATA_DIR, LOG_DIR)) code, _, _ = run(ssh, "id -u %s >/dev/null 2>&1" % SERVICE_USER) if code != 0: must(ssh, "sudo -n useradd --system --home-dir %s --shell /usr/sbin/nologin %s" % (APP_DIR, SERVICE_USER)) must(ssh, "sudo -n chown -R %s:%s %s %s" % (SERVICE_USER, SERVICE_USER, DATA_DIR, LOG_DIR)) must(ssh, "sudo -n chmod 750 %s" % LOG_DIR) must(ssh, "sudo -n rm -rf %s && mkdir -p %s" % (REMOTE_TMP, REMOTE_TMP)) # 3. 上传应用文件 for local, name in LOCAL_FILES: upload(ssh, local, "%s/%s" % (REMOTE_TMP, name)) for local, name in SYSTEMD_FILES: upload(ssh, local, "%s/systemd/%s" % (REMOTE_TMP, name)) upload(ssh, "index.html", "%s/index.html" % REMOTE_TMP) upload(ssh, "lzwlab.nginx.conf", "%s/lzwlab.cn.conf" % REMOTE_TMP) # 4. 生成配置(仅包含密码哈希,不保存明文) cfg = { "secret_key": auth.random_secret(), "cookie_secure": True, "max_content_length": 5 * 1024 ** 3, "data_dir": DATA_DIR, "database_path": DATA_DIR + "/files.db", # 远端为 Linux 路径,不能用 Windows 的 os.path.join "session_days": 7, "users": { args.username: {"password_hash": auth.hash_password(password)} }, } print(" [generate] 应用配置(用户:%s)" % args.username) sftp = ssh.open_sftp() try: with sftp.open("%s/config.json" % REMOTE_TMP, "w") as fh: fh.write(json.dumps(cfg, ensure_ascii=False, indent=2)) finally: sftp.close() # 5. 安装应用、配置与 systemd for _, name in LOCAL_FILES: must(ssh, "sudo -n install -m 644 -o root -g root %s/%s %s/%s" % (REMOTE_TMP, name, APP_DIR, name)) must(ssh, "sudo -n install -m 640 -o root -g %s %s/config.json %s" % (SERVICE_USER, REMOTE_TMP, CONFIG_PATH)) for _, name in SYSTEMD_FILES: must(ssh, "sudo -n install -m 644 -o root -g root %s/systemd/%s /etc/systemd/system/%s" % (REMOTE_TMP, name, name)) must(ssh, "sudo -n install -m 644 -o root -g root %s/index.html /var/www/lzwlab.cn/index.html" % REMOTE_TMP) must(ssh, "sudo -n install -m 644 -o root -g root %s/lzwlab.cn.conf " "/etc/nginx/sites-available/lzwlab.cn" % REMOTE_TMP) # 6. 启动服务与定时器 must(ssh, "sudo -n systemctl daemon-reload") must(ssh, "sudo -n systemctl enable --now lzwlab-transfer.service lzwlab-transfer-cleanup.timer") must(ssh, "sudo -n systemctl restart lzwlab-transfer.service") must(ssh, "sudo -n systemctl enable lzwlab-transfer-cleanup.timer && " "sudo -n systemctl start lzwlab-transfer-cleanup.timer") time.sleep(1) # 7. 校验并重载 nginx must(ssh, "sudo -n nginx -t") must(ssh, "sudo -n systemctl reload nginx") must(ssh, "sudo -n rm -rf %s" % REMOTE_TMP) print() print("== 部署完成,服务状态 ==") run(ssh, "systemctl is-active lzwlab-transfer.service lzwlab-transfer-cleanup.timer") run(ssh, "curl -s -o /dev/null -w 'local https /transfer/ => %{http_code}\n' " "-k https://127.0.0.1/transfer/ -H 'Host: lzwlab.cn'") print("应用入口:https://lzwlab.cn/transfer/") print("登录用户:%s(密码为环境变量 %s 中提供的值)" % (args.username, args.password_env)) finally: ssh.close() if __name__ == "__main__": main()