commit 7da61a0456bf76341293d87ecae835e88d206968 Author: cnb Date: Wed Aug 26 22:59:47 2026 +0800 Initial commit: lzwlab.cn server project (docs, nginx, gitea, rustdesk, file transfer app) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40291fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# ===== 机密文件:绝不提交 ===== +# 服务器凭据(明文密码) +Authentication.md + +# TLS 私钥 / 证书目录 +lzwlab.cn_nginx/ + +# ===== AI 助手本地数据 / 工具状态 ===== +.dsh/ +.dsh-meow/ + +# ===== 常见运行时产物 ===== +__pycache__/ +*.py[cod] +*.log +*.db +*.db-shm +*.db-wal +.env +.env.* +.DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7edc86a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +本项目的所有重要变更均记录于此。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。 + +## [Unreleased] + +### Added + +- 新增 Gitea Git 服务(https://lzwlab.cn/git/):Docker Compose 部署 Gitea 1.27.2(SQLite),管理员私有单实例,首页欢迎页新增“🐙 进入 Git 服务”入口。 +- 新增 gitea/docker-compose.yml 部署配置:Web 仅监听 127.0.0.1:3000,SSH 映射 2222→22,关闭开放注册并锁定安装器。 +- setup.md 新增第 10 节:Gitea 部署过程、子路径反代配置、管理员账号创建与验证结果。 +- 新增 RustDesk 自建远程桌面服务器:Docker Compose 部署 hbbs(ID/注册服务器)+ hbbr(中继服务器),host 网络监听 21115/21116(TCP+UDP)/21117/21118/21119,客户端以域名 lzwlab.cn 接入。 +- 新增 rustdesk/docker-compose.yml 部署配置;服务器安装 Docker(docker.io 29.1.3 + docker-compose-v2)并配置 daocloud 镜像加速(腾讯云访问 Docker Hub 被拒)。 +- setup.md 新增第 9 节:RustDesk 部署过程、验证结果与客户端配置说明。 +- 新增“文件快传”在线文件暂存工具,入口 https://lzwlab.cn/transfer/ ,首页欢迎页增加“进入文件快传”链接。 +- 文件快传支持指定用户登录鉴权、PC/手机响应式界面、多文件队列上传与下载进度显示、缓存文件列表、手动删除与手动清理。 +- 上传时可按 1 小时~30 天设置文件有效期(默认 7 天),过期文件由 systemd 定时器每 10 分钟自动删除。 +- 新增应用源码目录 transfer/ (Flask + gunicorn + SQLite)与幂等部署脚本 deploy_transfer.py 。 +- 新增 systemd 单元 lzwlab-transfer.service 与 lzwlab-transfer-cleanup.{service,timer} 。 + +### Changed + +- 更新首页 index.html :欢迎卡片增加“进入 Git 服务”入口链接(/git/)。 +- 更新 lzwlab.nginx.conf :按 Gitea 官方子路径(sub-path)方案新增 /git/ 反向代理到本机 3000 端口。 +- 更新 README.md 与 setup.md :补充 Gitea Git 服务部署记录与使用说明。 +- 更新首页 index.html :欢迎卡片下方增加文件快传入口链接。 +- 更新 lzwlab.nginx.conf :新增 /transfer/ 反向代理到本机 8090 端口,上传/下载超时调整为 3600 秒,上传体积上限 5120 MB。 +- 更新 README.md 与 setup.md :补充 RustDesk 自建服务器部署记录与客户端配置说明。 + +### Removed + +- 移除首页欢迎卡中的“网站搭建成功!这是部署在个人云服务器上的第一个页面。”提示信息,首页仅保留标题、Powered by Nginx 徽标与“文件快传”“Git 服务”两个入口链接。 + +### Security + +- Gitea 关闭开放注册并锁定安装器;管理员凭据仅记录于 Authentication.md。 +- Gitea SSH 克隆端口 2222 需在腾讯云安全组放行 TCP 2222 后方可从公网使用(HTTPS 克隆不受影响)。 +- RustDesk 服务使用 hbbs 首次启动自动生成的自建密钥(服务器 /opt/rustdesk/data/id_ed25519.pub),客户端必须配置该 Key 才能连接服务器。 +- 应用登录密码仅以 scrypt 哈希保存在 /etc/lzwlab-transfer/config.json (root:lzwlab 640),不在代码或仓库中保存明文。 +- 会话 Cookie 启用 Secure/HttpOnly/SameSite=Lax,登录接口按 IP 限流,写接口校验 X-Requested-With 头。 + +## [1.0.0] - 2026-08-24 + +初始版本:lzwlab.cn 静态站点上线。 + +### Added + +- 新增欢迎页 `index.html`(中文标题“欢迎访问 lzwlab.cn”)。 +- 新增 nginx 站点配置 `lzwlab.nginx.conf`:80 端口 301 跳转 443,启用 TLS 1.2/1.3 与 HTTP/2。 +- 新增 TLS 证书与私钥(TrustAsia DV,SAN:lzwlab.cn、www.lzwlab.cn),存放于 `lzwlab.cn_nginx/`。 +- 新增远程运维脚本 `remote.py`(Paramiko):支持远程执行命令(可 sudo)与文件上传。 +- 新增部署记录 `setup.md`,覆盖安装 nginx、上传站点、HTTPS 配置与验证全过程。 +- 新增文档维护技能 `.dsh/skills/maintain-docs/`,用于项目修改后自动更新 README 与 CHANGELOG。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..08644ad --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# lzwlab.cn 站点部署仓库 + +lzwlab.cn 个人云服务器上的站点部署项目:包含站点欢迎页、nginx 站点配置、TLS 证书、远程运维脚本,以及从首页链接进入的“文件快传”在线文件暂存工具。上线效果:http 请求自动 301 跳转到 https,`https://lzwlab.cn` 与 `https://www.lzwlab.cn` 均正常展示欢迎页,`https://lzwlab.cn/transfer/` 提供登录鉴权的文件上传/下载服务。服务器上还以 Docker 部署了 RustDesk 自建远程桌面服务(hbbs/hbbr,客户端以域名 `lzwlab.cn` 接入)与 Gitea Git 服务(入口 `https://lzwlab.cn/git/`,首页提供“🐙 进入 Git 服务”按钮)。 + +## 功能特性 + +- 静态欢迎页:`index.html`,响应式渐变卡片,中文标题“欢迎访问 lzwlab.cn”,并提供“📦 进入文件快传”“🐙 进入 Git 服务”两个入口。 +- HTTPS 全站:`lzwlab.nginx.conf` 将 80 端口请求 301 跳转到 443;443 端口启用 TLS 1.2/1.3 与 HTTP/2,证书链由 TrustAsia 签发。 +- 文件快传(`/transfer/`):指定用户登录后才能使用,PC/手机自适应;支持多文件上传与下载进度、有效期设置(默认 7 天,最短 1 小时,最长 30 天)、缓存列表与手动删除;过期文件由 systemd 定时器每 10 分钟自动清理。 +- 远程运维脚本:`remote.py` 基于 Paramiko,支持执行远程命令(可 sudo)与上传文件,凭据自动从 `Authentication.md` 解析,无需在命令行传密码。 +- RustDesk 自建服务器:Docker Compose 部署 hbbs(ID/注册服务器)+ hbbr(中继服务器),host 网络监听 21115-21119,客户端以域名 `lzwlab.cn` 接入,客户端配置说明见 [setup.md](setup.md) 第 9 节。 +- Gitea Git 服务(`https://lzwlab.cn/git/`):Docker 部署 Gitea 1.27.2(SQLite),管理员私有单实例,关闭开放注册;支持 HTTPS 克隆与 SSH 克隆(端口 2222),部署记录见 [setup.md](setup.md) 第 10 节。 +- 完整部署记录:`setup.md` 覆盖从环境检查、安装 nginx、上传站点、HTTPS 配置到验证的全过程,并附踩坑记录。 +- 文档维护技能:`.dsh/skills/maintain-docs/`,用于在项目修改后自动更新本 README 与 CHANGELOG.md。 + +## 目录结构 + +``` +. +├── Authentication.md # 服务器凭据(机密,勿公开/勿提交) +├── index.html # 站点欢迎页 +├── lzwlab.nginx.conf # nginx 站点配置(HTTP→HTTPS、TLS、HTTP/2) +├── lzwlab.cn_nginx/ # TLS 证书与私钥(私钥勿公开/勿提交) +│ ├── lzwlab.cn.csr +│ ├── lzwlab.cn.key +│ ├── lzwlab.cn_bundle.crt +│ └── lzwlab.cn_bundle.pem +├── remote.py # Paramiko 远程执行/上传脚本 +├── deploy_transfer.py # 文件快传一键部署脚本(幂等,可重复执行) +├── transfer/ # 文件快传应用源码 +│ ├── app.py # Flask 应用入口(gunicorn 启动) +│ ├── auth.py # 配置加载、scrypt 密码哈希、登录限流 +│ ├── storage.py # SQLite 元数据 + 磁盘缓存存储层 +│ ├── cleanup.py # 过期文件/孤儿缓存清理 CLI +│ ├── templates/index.html # 响应式单页前端(登录/上传队列/下载进度/文件列表) +│ └── systemd/ # systemd 服务与清理定时器单元 +│ ├── lzwlab-transfer.service +│ ├── lzwlab-transfer-cleanup.service +│ └── lzwlab-transfer-cleanup.timer +├── rustdesk/ # RustDesk 自建服务器 Compose 配置(hbbs/hbbr) +│ └── docker-compose.yml +├── gitea/ # Gitea Git 服务 Compose 配置(容器 gitea,SQLite) +│ └── docker-compose.yml +├── setup.md # 完整部署记录 +└── .dsh/skills/maintain-docs/ # 文档维护技能(README/CHANGELOG) + └── SKILL.md +``` + +## 环境要求 + +- 本地(运维机):Python 3 + Paramiko(`pip install paramiko`)。 +- 服务器:Ubuntu 24.04 LTS、nginx 1.24(systemd 管理)、Python 3.12、Flask 3.0.2 与 gunicorn 20.1(apt 安装)。 +- 服务器:Docker 29.1.3(apt docker.io + docker-compose-v2,已配置 daocloud 镜像加速)运行 RustDesk hbbs/hbbr 与 Gitea Git 服务。 +- 域名 `lzwlab.cn` / `www.lzwlab.cn` 的 DNS 已解析到服务器。 + +## 安装 + +本项目无需本地构建。站点文件与 nginx 配置通过 `remote.py` 上传到服务器后安装;文件快传应用通过 `deploy_transfer.py` 一键部署,完整步骤见下文“部署说明”。 + +## 使用说明 + +`remote.py` 用法: + +```bash +# 执行远程命令(sudo 命令需加 --sudo) +python remote.py "sudo nginx -t" --sudo +python remote.py "sudo systemctl reload nginx" --sudo + +# 上传文件(LOCAL 为本地路径,REMOTE 为远端路径) +python remote.py --put index.html /tmp/lzwlab-index.html +``` + +- 凭据由脚本从 `Authentication.md` 解析,无需在命令行传密码。 +- Windows Git Bash 提示:MSYS 会把以 `/` 开头的参数转换成 Windows 路径,上传远端路径时请加 `MSYS_NO_PATHCONV=1`。 + +部署文件快传(应用登录密码经环境变量传入,仅密码哈希会写入服务器配置): + +```bash +LZWLAB_APP_PASSWORD='$PASS' python deploy_transfer.py --username admin +``` + +验证站点: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/ # 200 +curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/transfer/ # 200(登录页) +curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/git/ # 200(Git 服务登录页) +``` + +## 部署说明 + +完整步骤见 [setup.md](setup.md)(环境检查 → 安装 nginx → 上传站点 → 配置 HTTP/HTTPS → 验证)。要点: + +- 站点根目录:`/var/www/lzwlab.cn`; +- nginx 配置安装到 `/etc/nginx/sites-available/lzwlab.cn` 并软链到 `sites-enabled/`,改配置后执行 `sudo systemctl reload nginx`; +- 证书安装到 `/etc/nginx/ssl/`(私钥权限 600、root:root); +- 文件快传:应用代码安装到 `/opt/lzwlab-transfer`,数据目录 `/var/lib/lzwlab-transfer`,配置 `/etc/lzwlab-transfer/config.json`(root:lzwlab 640);gunicorn 监听 `127.0.0.1:8090`,由 nginx 将 `/transfer/` 反向代理到该端口,systemd 定时器每 10 分钟执行一次过期清理。 +- RustDesk:Compose 文件安装在 `/opt/rustdesk`(hbbs+hbbr,host 网络,数据目录 `/opt/rustdesk/data`);客户端 ID 服务器/中继/Key 配置见 [setup.md](setup.md) 第 9 节。 +- Gitea:Compose 文件安装在 `/opt/gitea`(容器 `gitea`,SQLite 数据卷 `/opt/gitea/gitea`,Web 仅监听 `127.0.0.1:3000`,SSH 映射 `2222→22`);nginx 按 Gitea 官方 sub-path 方案将 `/git/` 反代到 3000 端口;管理员账号与密码记录在 `Authentication.md`,注册已关闭、安装器已锁定。详见 [setup.md](setup.md) 第 10 节。 + +## 安全与凭据说明 + +- `Authentication.md` 含服务器凭据,属机密:不要提交到公开仓库、不要写入任何对外文档;文档中密码一律以 `$PASS` 代替。 +- `lzwlab.cn_nginx/lzwlab.cn.key` 为私钥,上传到服务器后权限应为 600(root:root)。 +- 站点已启用 HTTPS 并强制 301 跳转,nginx 配置限定 TLS 1.2/1.3。 +- 文件快传登录密码只以 scrypt 哈希形式保存在 `/etc/lzwlab-transfer/config.json`,应用不保存明文密码;会话 Cookie 带 `Secure`、`HttpOnly`、`SameSite=Lax`,登录接口带按 IP 限流,写接口要求 `X-Requested-With` 头。 +- 文件快传缓存文件按有效期自动删除,请在服务器上自行控制磁盘空间。 +- RustDesk 使用 hbbs 首次启动自动生成的自建密钥(服务器 `/opt/rustdesk/data/id_ed25519.pub`),客户端必须配置该 Key 才能连接;Key 需分发给每个客户端使用,本身不属于机密凭据。 +- Gitea 关闭开放注册并锁定安装器;管理员密码只记录在 `Authentication.md`,对外文档一律用 `$PASS` 代替。 +- Gitea SSH 克隆端口为 2222,需在腾讯云安全组放行 TCP 2222 后才能从公网使用;HTTPS 克隆与网页访问不受影响。 + +## 参考资料 + +- [setup.md](setup.md):完整部署记录与踩坑记录 +- [CHANGELOG.md](CHANGELOG.md):变更日志(遵循 Keep a Changelog 与 SemVer) +- [index.html](index.html):站点页面源码 +- [deploy_transfer.py](deploy_transfer.py):文件快传部署脚本 +- [rustdesk/docker-compose.yml](rustdesk/docker-compose.yml):RustDesk 自建服务器 Compose 配置 +- [gitea/docker-compose.yml](gitea/docker-compose.yml):Gitea Git 服务 Compose 配置 +- [Gitea 官方文档](https://docs.gitea.com/):安装与反向代理(sub-path)参考 +- [transfer/](transfer/):文件快传应用源码 diff --git a/deploy_transfer.py b/deploy_transfer.py new file mode 100644 index 0000000..eb0a35a --- /dev/null +++ b/deploy_transfer.py @@ -0,0 +1,212 @@ +"""将 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() diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml new file mode 100644 index 0000000..60c1aeb --- /dev/null +++ b/gitea/docker-compose.yml @@ -0,0 +1,34 @@ +services: + server: + image: docker.gitea.com/gitea:1.27.2 + container_name: gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + # 站点设置:子路径 https://lzwlab.cn/git/(服务器无 git.lzwlab.cn 子域名,按官方文档使用 sub-path) + - GITEA__server__ROOT_URL=https://lzwlab.cn/git/ + - GITEA__server__DOMAIN=lzwlab.cn + - GITEA__server__SSH_DOMAIN=lzwlab.cn + - GITEA__server__SSH_PORT=2222 + - GITEA__server__SSH_LISTEN_PORT=22 + - GITEA__server__HTTP_PORT=3000 + # 数据库:SQLite(轻量单实例,数据卷持久化) + - GITEA__database__DB_TYPE=sqlite3 + # 安装锁 + 关闭开放注册(仅管理员可用) + - GITEA__security__INSTALL_LOCK=true + - GITEA__service__DISABLE_REGISTRATION=true + restart: always + networks: + - gitea + volumes: + - ./gitea:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + # Web 仅监听本机,由 nginx 通过 https://lzwlab.cn/git/ 反代 + - "127.0.0.1:3000:3000" + # SSH 端口 22 与主机 sshd 冲突,按官方文档映射到 2222 + - "2222:22" +networks: + gitea: + external: false \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..8ec2acb --- /dev/null +++ b/index.html @@ -0,0 +1,66 @@ + + + + + +欢迎访问 lzwlab.cn + + + +
+

🎉 欢迎访问 lzwlab.cn

+ Powered by Nginx +
+ 📦 进入文件快传 +
+ 🐙 进入 Git 服务 +
+ + \ No newline at end of file diff --git a/lzwlab.nginx.conf b/lzwlab.nginx.conf new file mode 100644 index 0000000..3686bb6 --- /dev/null +++ b/lzwlab.nginx.conf @@ -0,0 +1,62 @@ +server { + listen 80; + listen [::]:80; + server_name lzwlab.cn www.lzwlab.cn; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name lzwlab.cn www.lzwlab.cn; + + ssl_certificate /etc/nginx/ssl/lzwlab.cn_bundle.crt; + ssl_certificate_key /etc/nginx/ssl/lzwlab.cn.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + root /var/www/lzwlab.cn; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + # 文件快传应用:/transfer/ 反代到本机 8090 端口(Flask + gunicorn) + location = /transfer { + return 301 /transfer/; + } + location /transfer/ { + proxy_pass http://127.0.0.1:8090/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + client_max_body_size 5120m; + } + + # Gitea Git 服务:/git/ 反代到本机 3000 端口(Docker 容器) + # 子路径方案参考 https://docs.gitea.com/administration/reverse-proxies/ 的 "Nginx with a sub-path" + location ~ ^/(git)($|/) { + client_max_body_size 512M; + # 保持 "%2F" 原样,去掉 "/git" 前缀后转发给 Gitea + rewrite ^ $request_uri; + rewrite ^/(git($|/))?(.*) /$3 break; + proxy_pass http://127.0.0.1:3000$uri; + proxy_http_version 1.1; + proxy_set_header Connection $http_connection; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } +} \ No newline at end of file diff --git a/remote.py b/remote.py new file mode 100644 index 0000000..73b7941 --- /dev/null +++ b/remote.py @@ -0,0 +1,74 @@ +import argparse, re, sys, time +import paramiko + +def parse_auth(): + text = open("Authentication.md", encoding="utf-8").read() + def find(label): + m = re.search(label + r"\s*[::]\s*(\S+)", text) + if not m: + sys.exit("cannot parse " + label) + return m.group(1) + ip = find("IP地址") + user = find("用户名") + pw = find("密码") + root_pw = find("root密码") + return ip, user, pw, root_pw + +IP, USER, PW, ROOT_PW = parse_auth() + +def connect(): + 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(cmd, sudo=False, timeout=300): + cli = connect() + chan = cli.get_transport().open_session() + chan.settimeout(timeout) + chan.exec_command(cmd) + if sudo: + chan.sendall(PW + "\n") + try: + while True: + if chan.recv_ready(): + chunk = chan.recv(65536) + if not chunk: break + sys.stdout.buffer.write(chunk); sys.stdout.flush() + if chan.recv_stderr_ready(): + sys.stderr.buffer.write(chan.recv_stderr(65536)); sys.stderr.flush() + if chan.exit_status_ready(): + while chan.recv_ready(): + chunk = chan.recv(65536) + sys.stdout.buffer.write(chunk); sys.stdout.flush() + while chan.recv_stderr_ready(): + sys.stderr.buffer.write(chan.recv_stderr(65536)); sys.stderr.flush() + break + time.sleep(0.05) + except Exception as e: + print("\n[read-exception]", e) + rc = chan.recv_exit_status() if chan.exit_status_ready() else -1 + cli.close() + print(f"\n[exit={rc}]") + return rc + +def put(local, remote): + cli = connect() + sftp = cli.open_sftp() + sftp.put(local, remote) + sftp.close() + cli.close() + print(f"[uploaded {local} -> {USER}@{IP}:{remote}]") + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("command", nargs=argparse.REMAINDER) + ap.add_argument("--sudo", action="store_true") + ap.add_argument("--timeout", type=int, default=300) + ap.add_argument("--put", nargs=2, metavar=("LOCAL", "REMOTE")) + a = ap.parse_args() + if a.put: + put(a.put[0], a.put[1]) + else: + run(" ".join(a.command), sudo=a.sudo, timeout=a.timeout) diff --git a/rustdesk/docker-compose.yml b/rustdesk/docker-compose.yml new file mode 100644 index 0000000..1ac06d3 --- /dev/null +++ b/rustdesk/docker-compose.yml @@ -0,0 +1,28 @@ +# RustDesk 自建服务器(OSS 版)Docker Compose 配置 +# 参考:https://rustdesk.com/docs/zh-cn/self-host/rustdesk-server-oss/docker/ +# 使用 host 网络,监听端口(防火墙需放行): +# hbbs:21114/TCP(仅 Pro 需要)、21115/TCP(NAT 类型测试)、21116/TCP+UDP(ID 注册/心跳与打洞) +# hbbr:21117/TCP(中继)、21119/TCP(网页客户端) +# 网页客户端支持端口:21118/TCP(hbbs)、21119/TCP(hbbr) +# 密钥与数据保存在 ./data(容器内 /root):id_ed25519、id_ed25519.pub +services: + hbbs: + container_name: hbbs + image: rustdesk/rustdesk-server:latest + command: hbbs + volumes: + - ./data:/root + network_mode: "host" + + depends_on: + - hbbr + restart: unless-stopped + + hbbr: + container_name: hbbr + image: rustdesk/rustdesk-server:latest + command: hbbr + volumes: + - ./data:/root + network_mode: "host" + restart: unless-stopped diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..3137981 --- /dev/null +++ b/setup.md @@ -0,0 +1,510 @@ +# 服务器部署记录:nginx 欢迎页(lzwlab.cn) + +> 说明:服务器凭据见 `Authentication.md`,本文档中密码一律以 `$PASS` 代替。 +> 执行方式:本地通过 Paramiko 以 SSH 密码方式登录远程执行(远程用户 ubuntu,sudo 组免密)。 + +## 0. 环境信息 + +| 项目 | 值 | +| --- | --- | +| 服务器 IP | 1.15.225.173 | +| 登录用户 | ubuntu(sudo 组,`sudo -n` 可免密) | +| 系统 | Ubuntu 24.04.4 LTS(kernel 6.8.0-124-generic,腾讯云 VM) | +| 域名 | lzwlab.cn / www.lzwlab.cn(DNS 已解析到 1.15.225.173) | +| 防火墙 | ufw inactive(无需额外放行) | +| 磁盘 | 50G,已用 5.3G | + +## 1. 连接与初始状态检查 + +```bash +# 本地连接(密码 $PASS,取自 Authentication.md) +ssh ubuntu@1.15.225.173 + +# 登录后检查系统 +whoami && id && hostname && uname -a +cat /etc/os-release | head -3 + +# 检查 sudo 免密 +sudo -n true && echo sudo-ok + +# 检查 nginx(结果:未安装) +command -v nginx && nginx -v + +# 检查 80/443 端口占用(结果:空闲) +ss -tlnp | grep -E ":(80|443)" || echo no-80-443 + +# 检查防火墙(结果:inactive) +sudo -n ufw status + +# 检查域名解析(结果:均解析到 1.15.225.173) +getent hosts lzwlab.cn www.lzwlab.cn +``` + +本机验证 DNS(结果:Address: 1.15.225.173): + +```bash +nslookup lzwlab.cn +nslookup www.lzwlab.cn +``` + +## 2. 安装 nginx + +```bash +sudo apt-get update +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y nginx +``` + +结果:安装 nginx 1.24.0-2ubuntu7.17(nginx + nginx-common)。 +安装完成后 systemd 已自动启动 nginx 并注册开机自启(默认站点为 /var/www/html 的欢迎页)。 + +## 3. 创建站点内容 + +本地创建 `index.html`(欢迎页,中文标题“欢迎访问 lzwlab.cn”,1219 字节), +通过 SFTP 上传到服务器 /tmp,再用 root 安装到站点目录: + +```bash +# 本地:上传文件 +# (Paramiko SFTP: index.html -> /tmp/lzwlab-index.html) + +# 远程: +sudo mkdir -p /var/www/lzwlab.cn +sudo install -m 644 -o root -g root /tmp/lzwlab-index.html /var/www/lzwlab.cn/index.html +``` + +页面内容要点(详见仓库内 `index.html`): + +```html +

🎉 欢迎访问 lzwlab.cn

+Powered by Nginx +``` + +> 后续变更:2026-08-26 按需求移除了页面中的 `

网站搭建成功!这是部署在个人云服务器上的第一个页面。

` 提示信息(详见 CHANGELOG)。 + +## 4. 配置 nginx 站点 + +创建站点配置 `/etc/nginx/sites-available/lzwlab.cn`(内容同仓库内 `lzwlab.nginx.conf`): + +```nginx +server { + listen 80; + listen [::]:80; + server_name lzwlab.cn www.lzwlab.cn; + + root /var/www/lzwlab.cn; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} +``` + +启用站点并移除默认站点: + +```bash +# 上传配置 +# (Paramiko SFTP: lzwlab.nginx.conf -> /tmp/lzwlab.conf) + +# 远程: +sudo install -m 644 -o root -g root /tmp/lzwlab.conf /etc/nginx/sites-available/lzwlab.cn +sudo ln -sf /etc/nginx/sites-available/lzwlab.cn /etc/nginx/sites-enabled/lzwlab.cn +sudo rm -f /etc/nginx/sites-enabled/default +sudo rm -f /tmp/lzwlab-index.html /tmp/lzwlab.conf + +# 校验配置语法 +sudo nginx -t +# 结果:syntax is ok / test is successful +``` + +## 5. 启动 / 加载配置 + +```bash +sudo systemctl enable nginx +sudo systemctl start nginx +# 注意:apt 安装时 nginx 已启动并加载了默认配置, +# 所以修改配置后必须 reload 才会生效: +sudo nginx -t +sudo systemctl reload nginx +``` + +检查监听状态(结果:0.0.0.0:80 与 [::]:80 均已监听): + +```bash +sudo systemctl is-active nginx # active +ss -tlnp | grep :80 +``` + +## 6. 验证 + +服务器本机验证: + +```bash +curl -s -o /dev/null -w "status=%{http_code} size=%{size_download}\n" http://127.0.0.1/ -H "Host: lzwlab.cn" +# status=200 size=1219 +curl -s http://127.0.0.1/ -H "Host: lzwlab.cn" | grep -o ".*" +# 欢迎访问 lzwlab.cn +``` + +本地(公网域名)验证: + +```bash +curl -s -o /dev/null -w "status=%{http_code} size=%{size_download}\n" http://lzwlab.cn/ +# status=200 size=1219 +curl -s http://lzwlab.cn/ | grep -o ".*" +# 欢迎访问 lzwlab.cn +curl -s -o /dev/null -w "status=%{http_code}\n" http://www.lzwlab.cn/ +# status=200 +``` + +✅ 部署完成:http://lzwlab.cn 与 http://www.lzwlab.cn 均已正常展示欢迎页。 + +## 7. 部署 HTTPS 证书并配置 443 自动跳转 + +### 7.1 证书信息 + +本地证书目录 `lzwlab.cn_nginx/`: + +| 文件 | 用途 | +| --- | --- | +| `lzwlab.cn.key` | RSA 私钥(1700 字节) | +| `lzwlab.cn_bundle.crt` / `.pem` | 证书 + 证书链(两者内容相同) | +| `lzwlab.cn.csr` | 证书签名请求(部署用不到) | + +证书校验(本机 openssl): + +```bash +openssl x509 -in lzwlab.cn_nginx/lzwlab.cn_bundle.crt -noout -subject -issuer -dates +# subject=CN=lzwlab.cn +# issuer=TrustAsia DV TLS RSA CA 2024 +# 有效期:2026-08-24 ~ 2026-11-22 +openssl rsa -in lzwlab.cn_nginx/lzwlab.cn.key -check -noout # RSA key ok +``` + +SAN 包含:`DNS:lzwlab.cn` 和 `DNS:www.lzwlab.cn`。 + +### 7.2 上传证书与配置到服务器 + +```bash +# 本地:SFTP 上传到 /tmp +# lzwlab.cn_nginx/lzwlab.cn.key -> /tmp/lzwlab.cn.key +# lzwlab.cn_nginx/lzwlab.cn_bundle.crt -> /tmp/lzwlab.cn_bundle.crt +# lzwlab.nginx.conf -> /tmp/lzwlab.conf + +# 远程:安装证书/私钥并更新站点配置 +sudo mkdir -p /etc/nginx/ssl +sudo install -m 600 -o root -g root /tmp/lzwlab.cn.key /etc/nginx/ssl/lzwlab.cn.key +sudo install -m 644 -o root -g root /tmp/lzwlab.cn_bundle.crt /etc/nginx/ssl/lzwlab.cn_bundle.crt +sudo cp /etc/nginx/sites-available/lzwlab.cn /etc/nginx/sites-available/lzwlab.cn.bak +sudo install -m 644 -o root -g root /tmp/lzwlab.conf /etc/nginx/sites-available/lzwlab.cn +sudo rm -f /tmp/lzwlab.cn.key /tmp/lzwlab.cn_bundle.crt /tmp/lzwlab.conf +``` + +私钥权限 600、root:root;证书 644。旧配置备份为 `lzwlab.cn.bak`。 + +### 7.3 更新后的站点配置(`/etc/nginx/sites-available/lzwlab.cn`) + +```nginx +server { + listen 80; + listen [::]:80; + server_name lzwlab.cn www.lzwlab.cn; + return 301 https://$host$request_uri; # HTTP 全部 301 跳转到 HTTPS +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name lzwlab.cn www.lzwlab.cn; + + ssl_certificate /etc/nginx/ssl/lzwlab.cn_bundle.crt; + ssl_certificate_key /etc/nginx/ssl/lzwlab.cn.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + root /var/www/lzwlab.cn; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} +``` + +### 7.4 校验并加载 + +```bash +sudo nginx -t # syntax is ok / test is successful +sudo systemctl reload nginx +``` + +### 7.5 验证结果 + +服务器本机: + +```bash +curl -s -o /dev/null -w "http status=%{http_code} redirect=%{redirect_url}\n" http://127.0.0.1/ -H "Host: lzwlab.cn" +# status=301 redirect=https://lzwlab.cn/ +curl -s https://127.0.0.1/ -k -H "Host: lzwlab.cn" | grep -o ".*" +# 欢迎访问 lzwlab.cn +``` + +本地公网: + +```bash +curl -s -o /dev/null -w "status=%{http_code} -> %{redirect_url}\n" http://lzwlab.cn/ +# status=301 -> https://lzwlab.cn/ +curl -s -o /dev/null -w "status=%{http_code} ssl_verify=%{ssl_verify_result}\n" https://lzwlab.cn/ +# status=200 ssl_verify=0(证书链受信任) +curl -s -o /dev/null -w "status=%{http_code}\n" https://www.lzwlab.cn/ +# status=200 +echo | openssl s_client -connect lzwlab.cn:443 -servername lzwlab.cn -alpn h2,http/1.1 2>/dev/null | grep "ALPN" +# ALPN protocol: h2(HTTP/2 已启用) +``` + +✅ https://lzwlab.cn 与 https://www.lzwlab.cn 已启用 HTTPS;http 访问会自动 301 跳转到 https。 + +## 8. 部署“文件快传”工具(/transfer/) + +### 8.1 方案 + +- 后端:Flask 3.0.2 + gunicorn 20.1(apt 安装),监听 `127.0.0.1:8090`,systemd 服务 `lzwlab-transfer.service`。 +- 前端:单页 HTML(内嵌 CSS/JS),PC 与手机自适应,登录后可用。 +- 存储:上传文件保存在 `/var/lib/lzwlab-transfer/files/`,元数据在 SQLite `/var/lib/lzwlab-transfer/files.db`。 +- 配置:`/etc/lzwlab-transfer/config.json`(root:lzwlab 640),登录密码只保存 scrypt 哈希,明文经部署环境变量传入。 +- 清理:`lzwlab-transfer-cleanup.timer` 每 10 分钟执行 `cleanup.py` 删除过期文件;访问列表/手动清理时也会触发检查。 +- nginx:`/transfer/` 反向代理到 8090,`proxy_request_buffering off` + `proxy_buffering off` 保证大文件上传下载流式传输,`client_max_body_size 5120m`。 + +### 8.2 部署命令(本地运维机) + +```bash +# 依赖脚本见仓库 deploy_transfer.py;密码经环境变量传入,不会写入仓库 +LZWLAB_APP_PASSWORD='$PASS' python deploy_transfer.py --username admin +``` + +脚本会依次完成:apt 安装依赖 → 创建系统用户 `lzwlab` 与目录 → 上传/安装应用、配置、systemd 单元 → 启动服务与清理定时器 → 校验并 reload nginx。重复执行幂等。 + +### 8.3 验证结果 + +```bash +systemctl is-active lzwlab-transfer.service lzwlab-transfer-cleanup.timer # active active +curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/transfer/ # 200(登录页) +curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://lzwlab.cn/transfer/ # 301 -> https +``` + +公网 API 实测:登录成功返回会话 Cookie;未登录访问 `/api/files` 返回 401;上传 25 字节测试文件后列表可查,下载返回 200 且支持 `Range`(206);删除后列表为空;将测试文件过期时间强制置为过去并启动清理服务后,日志输出“清理完成:过期文件 1 个”,列表随即清空。 + +### 8.4 踩坑 + +- `deploy_transfer.py` 在 Windows 本地生成远端路径时不能使用 `os.path.join`(会把 `/` 变成 `\`),SQLite 路径必须手工拼接 Linux 路径,否则服务报 `unable to open database file`。 +- gunicorn 手动排障时不要在 SSH 会话里前台运行(会话断开会留下占端口的孤儿进程),应直接通过 `systemctl` 管理;排障后可 `sudo pkill -u lzwlab -f gunicorn && sudo systemctl restart lzwlab-transfer.service`。 +- Windows 下 curl 的 `-F 'file=@...;filename=中文名'` 对非 ASCII 文件名支持不佳,测试时用 ASCII 文件名,浏览器上传不受影响。 + +## 9. 部署 RustDesk 自建服务器(Docker) + +### 9.1 方案 + +- 组件:RustDesk Server OSS 版两个容器——hbbs(ID/注册服务器)与 hbbr(中继服务器),镜像 `rustdesk/rustdesk-server:latest`。 +- 网络:`network_mode: host`(官方推荐,无需 `-p` 映射),数据卷 `./data` 挂载到容器 `/root`(密钥、SQLite 库)。 +- 端口(防火墙需放行): + - hbbs:21115/TCP(NAT 类型测试)、21116/TCP+UDP(UDP 用于 ID 注册与心跳,TCP 用于打洞与连接服务)、21118/TCP(网页客户端); + - hbbr:21117/TCP(中继服务)、21119/TCP(网页客户端); + - 21114/TCP 网页控制台仅 Pro 版需要,OSS 版不监听。 +- 参考文档:https://rustdesk.com/docs/zh-cn/self-host/rustdesk-server-oss/docker/ ;客户端配置:https://rustdesk.com/docs/zh-cn/self-host/client-configuration/ + +### 9.2 安装 Docker 并配置镜像加速 + +```bash +sudo apt-get update +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 +sudo systemctl enable --now docker +docker --version # Docker version 29.1.3(apt docker.io 自带守护进程服务) +``` + +踩坑:腾讯云访问 Docker Hub 被拒(`registry-1.docker.io` connection refused),必须配置镜像加速: + +```bash +echo '{"registry-mirrors":["https://docker.m.daocloud.io"]}' | sudo tee /etc/docker/daemon.json +sudo systemctl restart docker +``` + +### 9.3 部署 hbbs/hbbr + +Compose 文件为仓库内 `rustdesk/docker-compose.yml`: + +```yaml +services: + hbbs: + container_name: hbbs + image: rustdesk/rustdesk-server:latest + command: hbbs + volumes: + - ./data:/root + network_mode: "host" + depends_on: + - hbbr + restart: unless-stopped + hbbr: + container_name: hbbr + image: rustdesk/rustdesk-server:latest + command: hbbr + volumes: + - ./data:/root + network_mode: "host" + restart: unless-stopped +``` + +部署命令: + +```bash +# 本地:SFTP 上传 rustdesk/docker-compose.yml 到 /tmp +sudo mkdir -p /opt/rustdesk +sudo install -m 644 -o root -g root /tmp/rustdesk-compose.yml /opt/rustdesk/compose.yml +cd /opt/rustdesk && sudo docker compose up -d +``` + +验证结果: + +```bash +sudo docker ps # hbbs、hbbr 均 Up +sudo ss -tlnup | grep -E '2111[4-9]' +# tcp 21115/21116、udp 21116、tcp 21118 归 hbbs;tcp 21117/21119 归 hbbr +sudo docker logs --tail 5 hbbs +# Key: UB1fNGcVYA9NTIEA7ra3Nm0R6o2m64lNQE24L7CIAkY= +# Listening on tcp/udp :21116 / Listening on tcp :21115 / Listening on websocket :21118 +``` + +### 9.4 客户端配置 + +RustDesk 客户端(设置 → 网络,点击“解锁”后可编辑)填写: + +| 项目 | 值 | +| --- | --- | +| ID 服务器 | `lzwlab.cn`(hbbs,端口 21116) | +| 中继服务器 | 留空自动推导;或填 `lzwlab.cn`(hbbr,端口 21117) | +| Key | `UB1fNGcVYA9NTIEA7ra3Nm0R6o2m64lNQE24L7CIAkY=` | +| API 服务器 | 留空(仅 Pro 版需要) | + +- Key 由 hbbs 首次启动自动生成,保存在服务器 `/opt/rustdesk/data/id_ed25519.pub`(容器内 `/root/id_ed25519.pub`),重建容器会复用;Key 需分发给每个客户端。 +- 连通性:本地已验证 21115-21119 TCP 公网可达;hbbs 日志可见外部客户端注册记录。UDP 21116 用于 ID 注册与心跳,若腾讯云控制台防火墙未放行 UDP 21116,客户端将无法注册。 + +### 9.5 运维 + +```bash +# 更新镜像并重建 +cd /opt/rustdesk && sudo docker compose pull && sudo docker compose up -d +# 查看日志 +sudo docker logs -f hbbs +sudo docker logs -f hbbr +# 查看密钥 +sudo cat /opt/rustdesk/data/id_ed25519.pub +``` + +## 10. 部署 Gitea Git 服务(/git/) + +### 10.1 方案 + +- 组件:Gitea 1.27.2 官方镜像 `docker.gitea.com/gitea:1.27.2`(Docker Compose,容器名 `gitea`),数据库 SQLite,数据卷 `./gitea:/data`(持久化在服务器 `/opt/gitea/gitea/`)。 +- 入口:服务器没有 `git.lzwlab.cn` 子域名解析,按 Gitea 官方反向代理文档的 sub-path 方案部署在 `https://lzwlab.cn/git/`(`ROOT_URL=https://lzwlab.cn/git/`)。 +- 端口:Web 仅监听 `127.0.0.1:3000`(不对公网直接暴露),由 nginx 将 `/git/` 反代到 3000;SSH 端口与主机 sshd 冲突,容器内 22 映射到主机 2222。 +- 账号:管理员私有单实例——关闭开放注册(`DISABLE_REGISTRATION=true`)、锁定安装器(`INSTALL_LOCK=true`),管理员 `admin`(密码见 Authentication.md)。 +- 参考文档:https://docs.gitea.com/installation/install-with-docker/ 与 https://docs.gitea.com/administration/reverse-proxies/ (“Nginx with a sub-path”)。 + +### 10.2 部署命令 + +Compose 文件为仓库内 `gitea/docker-compose.yml`: + +```bash +# 本地:SFTP 上传 gitea/docker-compose.yml 到 /tmp +sudo mkdir -p /opt/gitea +sudo install -m 644 -o root -g root /tmp/gitea-compose.yml /opt/gitea/compose.yml +sudo rm -f /tmp/gitea-compose.yml +cd /opt/gitea && sudo docker compose up -d +``` + +创建管理员并设置站点名: + +```bash +# 管理员(密码经变量传入,不写入仓库;首次启动完成后执行) +sudo docker exec -u git gitea gitea admin user create \ + --username admin --password '$PASS' --email admin@lzwlab.cn \ + --admin --must-change-password=false +# 站点名写入 app.ini 的 [DEFAULT] 段后重建容器 +sudo sed -i '1s/.*/APP_NAME = lzwlab Git 服务/' /opt/gitea/gitea/gitea/conf/app.ini +cd /opt/gitea && sudo docker compose up -d +``` + +### 10.3 nginx 反代(/git/) + +按官方 “Nginx with a sub-path” 方案(保持 `%2F` 原样并剥离 `/git` 前缀),在 443 server 块新增: + +```nginx +location ~ ^/(git)($|/) { + client_max_body_size 512M; + rewrite ^ $request_uri; + rewrite ^/(git($|/))?(.*) /$3 break; + proxy_pass http://127.0.0.1:3000$uri; + proxy_http_version 1.1; + proxy_set_header Connection $http_connection; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; +} +``` + +该配置已并入仓库内 `lzwlab.nginx.conf`,服务器 `/etc/nginx/sites-available/lzwlab.cn` 同步更新(改前自动备份 `lzwlab.cn.bak-YYYYMMDD-HHMMSS`),`sudo nginx -t` 通过后 reload。 + +### 10.4 验证结果 + +```bash +curl -s -o /dev/null -w "%{http_code}\n" https://lzwlab.cn/git/ # 200 +curl -s https://lzwlab.cn/git/api/v1/version # {"version":"1.27.2"} +curl -s https://lzwlab.cn/git/ | grep -o '.*' # lzwlab Git 服务 +curl -s https://lzwlab.cn/ | grep -o '进入 Git 服务' # 进入 Git 服务(首页按钮已生效) +# git 智能 HTTP 协议(创建测试仓库 hello 后): +curl -s 'https://lzwlab.cn/git/admin/hello.git/info/refs?service=git-upload-pack' +# 返回 service=git-upload-pack 与 HEAD 引用;测试仓库与临时令牌验证后已删除 +``` + +关键配置项(`/opt/gitea/gitea/gitea/conf/app.ini`):`ROOT_URL=https://lzwlab.cn/git/`、`DOMAIN/SSH_DOMAIN=lzwlab.cn`、`SSH_PORT=2222`、`SSH_LISTEN_PORT=22`(容器内)、`INSTALL_LOCK=true`、`DISABLE_REGISTRATION=true`、`APP_NAME=lzwlab Git 服务`。 + +### 10.5 使用与运维 + +- 克隆地址: + - HTTPS(公网可用):`https://lzwlab.cn/git/<用户名>/<仓库>.git`; + - SSH(需先放行安全组,见 10.6):`ssh://git@lzwlab.cn:2222/<用户名>/<仓库>.git`。 +- 运维命令: + +```bash +sudo docker compose -f /opt/gitea/compose.yml ps # 容器状态 +sudo docker logs -f gitea # 日志 +cd /opt/gitea && sudo docker compose pull && sudo docker compose up -d # 升级镜像 +sudo docker exec -u git gitea gitea admin user list # 用户列表 +``` + +- 备份:SQLite 库、仓库与配置全部在数据卷内,直接备份 `/opt/gitea/gitea/` 即可。 + +### 10.6 踩坑 + +- `GITEA__APP_NAME` 环境变量传入容器后未生效(站点名仍显示默认值),站点名需直接写入 `app.ini` 的 `[DEFAULT]` 段(`APP_NAME = ...`)并重建容器。 +- 腾讯云安全组只放行 22/443/21115-21119,主机映射的 2222 端口公网不可达(本机探测 TIMEOUT,服务器本地可达):SSH 克隆需在腾讯云控制台为 TCP 2222 添加入站规则;HTTPS 克隆与网页访问不受影响。 +- Gitea 子路径部署必须同时满足两点:`ROOT_URL` 带 `/git/` 后缀,且 nginx 使用官方 rewrite 方案剥离前缀(直接 `proxy_pass http://127.0.0.1:3000/;` 会破坏前缀与 `%2F` 转义)。 +- 镜像使用官方新仓库 `docker.gitea.com/gitea:1.27.2`(腾讯云可直连,无需镜像加速);若改用 Docker Hub 的 `gitea/gitea` 则会走 daocloud 加速。 + +## 附:踩坑记录 + + +1. 本地 Git Bash 调用 Windows Python 时,MSYS 会把以 `/` 开头的命令行参数(如 `/tmp/...`)转换成 Windows 路径, + 导致 SFTP 上传远端路径错误(ENOENT)。解决:调用前加 `MSYS_NO_PATHCONV=1`,或把路径写进脚本内。 +2. apt 安装 nginx 会自动启动服务并加载默认站点配置;改完站点配置后 `systemctl start` 不会重新加载, + 必须 `systemctl reload nginx`(或 `restart`),否则看到的仍是默认欢迎页。 +3. 该服务器上的 nginx 1.24(Ubuntu 构建)不识别独立指令 `http2 on;`,`nginx -t` 报 unknown directive "http2"; + 启用 HTTP/2 应写成 `listen 443 ssl http2;`。 +4. 腾讯云服务器访问 Docker Hub 被拒(pull 报 connection refused),通过 `/etc/docker/daemon.json` 配置 + `registry-mirrors`(docker.m.daocloud.io)后解决。 +5. `remote.py` 的 `--timeout` 参数必须放在远程命令之前(`python remote.py --timeout 500 "命令"`); + 放在命令之后会被 argparse 当作远程命令的一部分传给服务器执行。 diff --git a/transfer/app.py b/transfer/app.py new file mode 100644 index 0000000..4b56eb7 --- /dev/null +++ b/transfer/app.py @@ -0,0 +1,268 @@ +"""lzwlab.cn 文件快传 —— Flask 应用入口。 + +生产环境由 gunicorn 启动: + gunicorn --workers 1 --threads 4 --bind 127.0.0.1:8090 app:app +""" +import functools +import os +import re +import secrets +import time +from datetime import timedelta + +from flask import (Flask, jsonify, redirect, render_template, request, + send_file, session) +from werkzeug.exceptions import RequestEntityTooLarge + +import auth +import storage + +cfg = auth.load_config() +FILES_DIR = cfg["files_dir"] +DB_PATH = cfg["database_path"] +USERS = cfg["users"] +MIN_EXPIRES_SECONDS = 3600 # 最短 1 小时 +MAX_EXPIRES_SECONDS = 30 * 24 * 3600 # 最长 30 天 +DEFAULT_EXPIRES_HOURS = 168 # 默认 7 天 + +os.makedirs(FILES_DIR, exist_ok=True) +storage.init_db(DB_PATH) + +app = Flask(__name__) +app.config.update( + SECRET_KEY=cfg["secret_key"], + MAX_CONTENT_LENGTH=cfg["max_content_length"], + SESSION_COOKIE_NAME="lzwlab_transfer_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=cfg["cookie_secure"], + SESSION_COOKIE_PATH=cfg.get("cookie_path", "/transfer/"), + PERMANENT_SESSION_LIFETIME=timedelta(days=cfg["session_days"]), +) + +login_limiter = auth.RateLimiter(max_failures=10, window_seconds=900) + + +def client_ip() -> str: + fwd = request.headers.get("X-Forwarded-For", "") + if fwd: + return fwd.split(",")[0].strip() + return request.remote_addr or "unknown" + + +def require_xhr(): + if request.headers.get("X-Requested-With", "").lower() != "xmlhttprequest": + return jsonify(ok=False, error="非法请求:缺少 X-Requested-With"), 400 + + +def login_required(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + user = session.get("user") + if not user or user not in USERS: + if request.path.startswith("/api/"): + return jsonify(ok=False, error="未登录或会话已过期"), 401 + return redirect("/") + return fn(*args, **kwargs) + return wrapper + + +@app.after_request +def harden(response): + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + if request.path.startswith("/api/"): + response.headers.setdefault("Cache-Control", "no-store") + if request.endpoint == "index": + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " + "script-src 'self' 'unsafe-inline'; connect-src 'self'; " + "font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'", + ) + return response + + +@app.errorhandler(RequestEntityTooLarge) +def too_large(_err): + if request.path.startswith("/api/"): + return jsonify(ok=False, error="文件大小超过允许上限"), 413 + return "文件过大", 413 + + +@app.errorhandler(404) +def not_found(err): + if request.path.startswith("/api/"): + return jsonify(ok=False, error="资源不存在"), 404 + return err + + +def _parse_expires_hours(value) -> int: + try: + hours = float(value) + except (TypeError, ValueError): + raise ValueError("有效期格式不正确") + if not (1 <= hours <= 720): + raise ValueError("有效期最短 1 小时,最长 30 天(720 小时)") + seconds = int(round(hours * 3600)) + if not (MIN_EXPIRES_SECONDS <= seconds <= MAX_EXPIRES_SECONDS): + raise ValueError("有效期超出允许范围") + return seconds + + +def _clean_name(raw_name) -> str: + name = str(raw_name or "").strip() or "未命名文件" + name = name.replace("\\", "/").split("/")[-1] + name = re.sub(r"[\x00-\x1f\x7f]", "", name) + return (name[:200] or "未命名文件") + + +@app.get("/") +def index(): + return render_template("index.html") + + +@app.get("/healthz") +def healthz(): + return jsonify(ok=True) + + +@app.get("/api/session") +def api_session(): + user = session.get("user") + if user and user in USERS: + return jsonify(logged_in=True, user=user) + return jsonify(logged_in=False) + + +@app.post("/api/login") +def api_login(): + ip = client_ip() + if login_limiter.is_blocked(ip): + return jsonify(ok=False, error="尝试次数过多,请 15 分钟后再试"), 429 + data = request.get_json(silent=True) or {} + username = str(data.get("username", "")).strip() + password = str(data.get("password", "")) + user = USERS.get(username) + ok = bool(user) and auth.verify_password(password, user.get("password_hash", "")) + if not ok: + login_limiter.record_failure(ip) + time.sleep(0.3) + return jsonify(ok=False, error="用户名或密码错误"), 401 + login_limiter.reset(ip) + session.clear() + session.permanent = True + session["user"] = username + return jsonify(ok=True, user=username) + + +@app.post("/api/logout") +def api_logout(): + session.clear() + return jsonify(ok=True) + + +@app.post("/api/upload") +@login_required +def api_upload(): + bad = require_xhr() + if bad: + return bad + if "file" not in request.files: + return jsonify(ok=False, error="未选择文件"), 400 + upload = request.files["file"] + if upload is None or upload.filename is None: + return jsonify(ok=False, error="未选择文件"), 400 + try: + expires_seconds = _parse_expires_hours(request.form.get("expires_hours", DEFAULT_EXPIRES_HOURS)) + except ValueError as exc: + return jsonify(ok=False, error=str(exc)), 400 + + file_id = secrets.token_hex(8) + stored_path = os.path.join(FILES_DIR, file_id) + name = _clean_name(upload.filename) + try: + upload.save(stored_path) + except RequestEntityTooLarge: + raise + except Exception: + app.logger.exception("upload save failed: %s", file_id) + try: + os.remove(stored_path) + except OSError: + pass + return jsonify(ok=False, error="上传失败,请重试"), 500 + + size = os.path.getsize(stored_path) + now = int(time.time()) + storage.insert_file(DB_PATH, file_id, name, stored_path, size, now + expires_seconds, now) + return jsonify(ok=True, file={ + "id": file_id, + "name": name, + "size": size, + "created_at": storage.iso(now), + "expires_at": storage.iso(now + expires_seconds), + "remaining_seconds": expires_seconds, + "downloads": 0, + }) + + +@app.get("/api/files") +@login_required +def api_files(): + now = int(time.time()) + storage.cleanup_expired(DB_PATH, FILES_DIR, now=now) + return jsonify(files=storage.list_files(DB_PATH, now)) + + +@app.get("/api/download/") +@login_required +def api_download(file_id): + now = int(time.time()) + row = storage.get_file(DB_PATH, file_id) + if row is None: + return jsonify(ok=False, error="文件不存在或已过期"), 404 + if row["expires_at"] <= now: + storage.cleanup_expired(DB_PATH, FILES_DIR, now=now) + return jsonify(ok=False, error="文件已过期"), 404 + if not os.path.isfile(row["stored_path"]): + storage.delete_file(DB_PATH, FILES_DIR, file_id) + return jsonify(ok=False, error="缓存文件已丢失"), 404 + storage.increment_downloads(DB_PATH, file_id) + return send_file( + row["stored_path"], + as_attachment=True, + download_name=row["name"], + conditional=True, + etag=True, + max_age=0, + ) + + +@app.delete("/api/files/") +@login_required +def api_delete_file(file_id): + bad = require_xhr() + if bad: + return bad + ok = storage.delete_file(DB_PATH, FILES_DIR, file_id) + if not ok: + return jsonify(ok=False, error="文件不存在"), 404 + return jsonify(ok=True) + + +@app.post("/api/cleanup") +@login_required +def api_cleanup(): + bad = require_xhr() + if bad: + return bad + now = int(time.time()) + removed = storage.cleanup_expired(DB_PATH, FILES_DIR, now=now) + orphans = storage.cleanup_orphans(DB_PATH, FILES_DIR, now=now) + return jsonify(ok=True, removed=removed, orphans=orphans) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8090, debug=False) diff --git a/transfer/auth.py b/transfer/auth.py new file mode 100644 index 0000000..4528ea4 --- /dev/null +++ b/transfer/auth.py @@ -0,0 +1,101 @@ +"""认证、配置加载与登录限流工具(仅依赖标准库)。""" +import base64 +import hashlib +import hmac +import json +import os +import secrets +import threading +import time + +DEFAULT_CONFIG_PATH = os.environ.get("LZWLAB_TRANSFER_CONFIG", "/etc/lzwlab-transfer/config.json") + +_SCRYPT_N = 2 ** 14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 + + +def _b64encode(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + +def _b64decode(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def hash_password(password: str) -> str: + """返回 scrypt 密码哈希,格式:scrypt$N$r$p$salt_b64$hash_b64""" + if not isinstance(password, str) or not password: + raise ValueError("密码不能为空") + salt = secrets.token_bytes(16) + digest = hashlib.scrypt( + password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32 + ) + return "scrypt$%d$%d$%d$%s$%s" % ( + _SCRYPT_N, _SCRYPT_R, _SCRYPT_P, _b64encode(salt), _b64encode(digest) + ) + + +def verify_password(password: str, stored: str) -> bool: + try: + parts = stored.split("$") + if len(parts) != 6 or parts[0] != "scrypt": + return False + _, n_s, r_s, p_s, salt_s, digest_s = parts + n, r, p = int(n_s), int(r_s), int(p_s) + digest = hashlib.scrypt( + password.encode("utf-8"), salt=_b64decode(salt_s), n=n, r=r, p=p, dklen=32 + ) + return hmac.compare_digest(digest, _b64decode(digest_s)) + except Exception: + return False + + +def load_config(path: str | None = None) -> dict: + path = path or DEFAULT_CONFIG_PATH + if not os.path.exists(path): + raise RuntimeError("配置文件不存在:%s" % path) + with open(path, "r", encoding="utf-8") as fh: + cfg = json.load(fh) + if not isinstance(cfg.get("users"), dict) or not cfg["users"]: + raise RuntimeError("配置缺少 users") + if not cfg.get("secret_key") or len(str(cfg["secret_key"])) < 32: + raise RuntimeError("配置缺少 secret_key(至少 32 字符)") + data_dir = cfg.get("data_dir", "/var/lib/lzwlab-transfer") + cfg["files_dir"] = os.path.join(data_dir, "files") + cfg["database_path"] = cfg.get("database_path", os.path.join(data_dir, "files.db")) + cfg["max_content_length"] = int(cfg.get("max_content_length", 5 * 1024 ** 3)) + cfg["cookie_secure"] = bool(cfg.get("cookie_secure", True)) + cfg["session_days"] = int(cfg.get("session_days", 7)) + return cfg + + +def random_secret() -> str: + return secrets.token_urlsafe(48) + + +class RateLimiter: + """基于内存的按 IP 登录失败限流。""" + + def __init__(self, max_failures: int = 10, window_seconds: int = 900): + self.max_failures = max_failures + self.window_seconds = window_seconds + self._lock = threading.Lock() + self._failures: dict[str, list[float]] = {} + + def _prune(self, ip: str, now: float) -> None: + self._failures[ip] = [t for t in self._failures.get(ip, []) if now - t < self.window_seconds] + + def is_blocked(self, ip: str) -> bool: + with self._lock: + self._prune(ip, time.monotonic()) + return len(self._failures.get(ip, [])) >= self.max_failures + + def record_failure(self, ip: str) -> None: + with self._lock: + self._failures.setdefault(ip, []).append(time.monotonic()) + self._prune(ip, time.monotonic()) + + def reset(self, ip: str) -> None: + with self._lock: + self._failures.pop(ip, None) diff --git a/transfer/cleanup.py b/transfer/cleanup.py new file mode 100644 index 0000000..eadcb88 --- /dev/null +++ b/transfer/cleanup.py @@ -0,0 +1,25 @@ +"""手动/定时清理过期文件与孤儿缓存文件。""" +import logging +import time + +import auth +import storage + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("lzwlab-cleanup") + + +def main() -> int: + cfg = auth.load_config() + files_dir = cfg["files_dir"] + db_path = cfg["database_path"] + storage.init_db(db_path) + now = int(time.time()) + removed = storage.cleanup_expired(db_path, files_dir, now=now) + orphans = storage.cleanup_orphans(db_path, files_dir, now=now) + log.info("清理完成:过期文件 %d 个,孤儿缓存 %d 个", removed, orphans) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/transfer/storage.py b/transfer/storage.py new file mode 100644 index 0000000..def1f09 --- /dev/null +++ b/transfer/storage.py @@ -0,0 +1,174 @@ +"""文件元数据与磁盘缓存的存储层(SQLite + 文件目录,仅标准库)。""" +import os +import sqlite3 +import threading +from datetime import datetime, timezone + +_DB_LOCK = threading.Lock() + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS files ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + stored_path TEXT NOT NULL, + size INTEGER NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + downloads INTEGER NOT NULL DEFAULT 0 +); +""" + + +def connect(db_path: str) -> sqlite3.Connection: + conn = sqlite3.connect(db_path, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=30000") + return conn + + +def init_db(db_path: str) -> None: + os.makedirs(os.path.dirname(db_path), exist_ok=True) + with _DB_LOCK: + conn = connect(db_path) + try: + conn.executescript(SCHEMA) + conn.commit() + finally: + conn.close() + + +def iso(ts: int) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +def file_to_dict(row: sqlite3.Row, now: int) -> dict: + return { + "id": row["id"], + "name": row["name"], + "size": row["size"], + "created_at": iso(row["created_at"]), + "expires_at": iso(row["expires_at"]), + "remaining_seconds": max(0, row["expires_at"] - now), + "downloads": row["downloads"], + } + + +def insert_file(db_path: str, file_id: str, name: str, stored_path: str, + size: int, expires_at: int, created_at: int) -> None: + with _DB_LOCK: + conn = connect(db_path) + try: + conn.execute( + "INSERT INTO files (id, name, stored_path, size, created_at, expires_at, downloads) " + "VALUES (?, ?, ?, ?, ?, ?, 0)", + (file_id, name, stored_path, size, created_at, expires_at), + ) + conn.commit() + finally: + conn.close() + + +def get_file(db_path: str, file_id: str): + with _DB_LOCK: + conn = connect(db_path) + try: + return conn.execute("SELECT * FROM files WHERE id = ?", (file_id,)).fetchone() + finally: + conn.close() + + +def list_files(db_path: str, now: int) -> list[dict]: + with _DB_LOCK: + conn = connect(db_path) + try: + rows = conn.execute( + "SELECT * FROM files WHERE expires_at > ? ORDER BY created_at DESC", (now,) + ).fetchall() + return [file_to_dict(r, now) for r in rows] + finally: + conn.close() + + +def increment_downloads(db_path: str, file_id: str) -> None: + with _DB_LOCK: + conn = connect(db_path) + try: + conn.execute("UPDATE files SET downloads = downloads + 1 WHERE id = ?", (file_id,)) + conn.commit() + finally: + conn.close() + + +def delete_file(db_path: str, files_dir: str, file_id: str) -> bool: + """删除数据库记录并移除磁盘文件,返回是否删除过数据库记录。""" + with _DB_LOCK: + conn = connect(db_path) + try: + row = conn.execute("SELECT id, stored_path FROM files WHERE id = ?", (file_id,)).fetchone() + if row is None: + return False + conn.execute("DELETE FROM files WHERE id = ?", (file_id,)) + conn.commit() + finally: + conn.close() + try: + os.remove(row["stored_path"]) + except FileNotFoundError: + pass + except OSError: + pass + return True + + +def cleanup_expired(db_path: str, files_dir: str, now: int | None = None) -> int: + now = now if now is not None else int(__import__("time").time()) + removed = 0 + with _DB_LOCK: + conn = connect(db_path) + try: + rows = conn.execute( + "SELECT id, stored_path FROM files WHERE expires_at <= ?", (now,) + ).fetchall() + for row in rows: + try: + os.remove(row["stored_path"]) + except FileNotFoundError: + pass + except OSError: + continue # 磁盘文件暂时删不掉时保留记录,下次重试 + conn.execute("DELETE FROM files WHERE id = ?", (row["id"],)) + removed += 1 + conn.commit() + finally: + conn.close() + return removed + + +def cleanup_orphans(db_path: str, files_dir: str, now: int | None = None, + min_age_seconds: int = 172800) -> int: + """清理磁盘上不在数据库且超过 48 小时未修改的孤儿文件。""" + import time as _time + now = now if now is not None else int(_time.time()) + removed = 0 + with _DB_LOCK: + conn = connect(db_path) + try: + ids = {r["id"] for r in conn.execute("SELECT id FROM files").fetchall()} + finally: + conn.close() + if not os.path.isdir(files_dir): + return 0 + for name in os.listdir(files_dir): + if name in ids: + continue + path = os.path.join(files_dir, name) + if not os.path.isfile(path): + continue + try: + if now - os.path.getmtime(path) >= min_age_seconds: + os.remove(path) + removed += 1 + except OSError: + pass + return removed diff --git a/transfer/systemd/lzwlab-transfer-cleanup.service b/transfer/systemd/lzwlab-transfer-cleanup.service new file mode 100644 index 0000000..c8587ae --- /dev/null +++ b/transfer/systemd/lzwlab-transfer-cleanup.service @@ -0,0 +1,10 @@ +[Unit] +Description=lzwlab.cn file transfer cleanup (expired files) +After=local-fs.target + +[Service] +Type=oneshot +User=lzwlab +Group=lzwlab +Environment=LZWLAB_TRANSFER_CONFIG=/etc/lzwlab-transfer/config.json +ExecStart=/usr/bin/python3 /opt/lzwlab-transfer/cleanup.py diff --git a/transfer/systemd/lzwlab-transfer-cleanup.timer b/transfer/systemd/lzwlab-transfer-cleanup.timer new file mode 100644 index 0000000..03f8010 --- /dev/null +++ b/transfer/systemd/lzwlab-transfer-cleanup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Cleanup expired files every 10 minutes + +[Timer] +OnBootSec=1min +OnUnitActiveSec=10min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/transfer/systemd/lzwlab-transfer.service b/transfer/systemd/lzwlab-transfer.service new file mode 100644 index 0000000..f6e7c6a --- /dev/null +++ b/transfer/systemd/lzwlab-transfer.service @@ -0,0 +1,16 @@ +[Unit] +Description=lzwlab.cn file transfer web app +After=network.target + +[Service] +Type=simple +User=lzwlab +Group=lzwlab +WorkingDirectory=/opt/lzwlab-transfer +Environment=LZWLAB_TRANSFER_CONFIG=/etc/lzwlab-transfer/config.json +ExecStart=/usr/bin/gunicorn --workers 1 --threads 4 --timeout 3600 --graceful-timeout 30 --bind 127.0.0.1:8090 --access-logfile /var/log/lzwlab-transfer/access.log --error-logfile /var/log/lzwlab-transfer/error.log app:app +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/transfer/templates/index.html b/transfer/templates/index.html new file mode 100644 index 0000000..e805a3e --- /dev/null +++ b/transfer/templates/index.html @@ -0,0 +1,917 @@ + + + + + + + +文件快传 · lzwlab.cn + + + + + + + + +
+ + + + + +