diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff57b26 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +__pycache__ +*.pyc +vpngate_data +dist +build +tests +scratch +_site diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4e0704c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Publish formal release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + packages: write + +jobs: + test: + name: Test Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.11", "3.13"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + - name: Verify version tag + shell: bash + run: test "${GITHUB_REF_NAME}" = "v$(tr -d '\r\n' < VERSION)" + - name: Compile Python sources + run: python -m py_compile vpngate_manager.py vpn_utils.py proxy_server.py snapshot_utils.py + - name: Validate installation script + run: bash -n install.sh + - name: Validate Docker Compose configuration + run: docker compose -f compose.yaml config >/dev/null + - name: Run unit tests + run: python -m unittest discover -s tests -v + + release: + name: Build Linux release archives + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + - name: Build architecture-labelled archives + run: python scripts/build_release_archives.py --output-dir dist + - name: Create GitHub formal release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + title="AimiliVPN V$(cut -d. -f1,2 VERSION) 正式版" + if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" dist/* --clobber --repo "${GITHUB_REPOSITORY}" + gh release edit "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" --title "${title}" --notes-file RELEASE_NOTES.md + else + gh release create "${GITHUB_REF_NAME}" dist/* --repo "${GITHUB_REPOSITORY}" --title "${title}" --notes-file RELEASE_NOTES.md --verify-tag + fi + + docker: + name: Build multi-architecture Docker image + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve image tags + id: version + shell: bash + run: | + version="${GITHUB_REF_NAME#v}" + minor="$(printf '%s' "${version}" | cut -d. -f1,2)" + echo "version=${version}" >> "${GITHUB_OUTPUT}" + echo "minor=${minor}" >> "${GITHUB_OUTPUT}" + - uses: docker/build-push-action@v7 + with: + context: . + push: true + platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v7 + build-args: BUILD_VERSION=${{ steps.version.outputs.version }} + tags: | + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} + ghcr.io/${{ github.repository }}:${{ steps.version.outputs.minor }} + ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: mode=max + sbom: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c07dbba --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM debian:bookworm-slim + +ARG TARGETARCH +ARG TARGETVARIANT +ARG BUILD_VERSION=dev + +LABEL org.opencontainers.image.title="AimiliVPN" \ + org.opencontainers.image.description="VPNGate node manager with HTTP and SOCKS5 proxy" \ + org.opencontainers.image.source="https://github.com/baoweise-bot/aimili-vpngate" \ + org.opencontainers.image.version="${BUILD_VERSION}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + iproute2 \ + iptables \ + openvpn \ + procps \ + psmisc \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY VERSION README.md LICENSE ./ +COPY vpngate_manager.py vpn_utils.py proxy_server.py snapshot_utils.py ./ +COPY mirror ./mirror + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + VPNGATE_DATA_DIR=/data \ + UI_HOST=0.0.0.0 \ + UI_PORT=8787 \ + LOCAL_PROXY_HOST=127.0.0.1 \ + LOCAL_PROXY_PORT=7928 + +RUN mkdir -p /data \ + && python3 -m py_compile vpngate_manager.py vpn_utils.py proxy_server.py snapshot_utils.py + +VOLUME ["/data"] +EXPOSE 8787/tcp 7928/tcp +STOPSIGNAL SIGTERM + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD python3 -c "import os,socket; s=socket.create_connection(('127.0.0.1',int(os.environ.get('UI_PORT','8787'))),3); s.close()" + +CMD ["python3", "vpngate_manager.py"] diff --git a/README.md b/README.md index b7b4021..258b020 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # AimiliVPN 🌐 +[![正式版](https://img.shields.io/badge/正式版-V2.1-16a34a?style=flat-square)](https://github.com/baoweise-bot/aimili-vpngate/releases/latest) +[![主分支](https://img.shields.io/badge/更新通道-main-2563eb?style=flat-square)](https://github.com/baoweise-bot/aimili-vpngate/tree/main) +[![Docker](https://img.shields.io/badge/GHCR-amd64%20%7C%20386%20%7C%20arm64%20%7C%20armv7-0ea5e9?style=flat-square)](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate) + Bilingual: [中文](#中文) | [English](#english) --- @@ -11,6 +15,38 @@ AimiliVPN 是一款基于官方 VPNGate 开放协议的高性能、零依赖 VPN --- +### 📌 当前正式版本:V2.1 + +V2.1 是项目启用正式版本标志后的首个稳定版本。仓库、安装器、命令行更新和 Web 更新检测现在全部统一使用 **`main` 主分支正式通道**。 + +#### V2.1 更新进展 + +- **节点来源容灾**:依次尝试 VPNGate 官方 HTTPS、官方 HTTP、GitHub Pages HTTPS、GitHub Pages HTTP、VPS 本地最近有效快照和仓库内置初始快照。 +- **获取与切换修复**:缩短被 VPNGate 域名封锁的 VPS 等待时间;切换新节点前先完成预检,目标失败时保留当前可用连接。 +- **节点可视化**:恢复延迟列,优先显示本机实测延迟;没有实测值时显示 VPNGate 官方预估值并明确标注“仅供参考”。 +- **国家筛选**:支持带国旗和节点数量的实时多选筛选,选择范围保存到本机,并作用于手动更新和后台周期同步。 +- **节点操作**:恢复单节点“检测”按钮,补齐收藏、检测、连接和断开状态逻辑。 +- **镜像同步**:GitHub Pages 每 15 分钟同步并校验官方节点快照,官方 API 被屏蔽时自动回退。 +- **Web 更新检测**:页面顶部显示 `V2.1 正式版`,可直接检查 GitHub 最新稳定 Release;只展示 `main` 和正式版下载入口。 +- **正式发布链路**:GitHub 标签自动运行 Python 兼容测试、构建四类 Linux 发行包、生成 SHA-256 校验文件并发布多架构 Docker 镜像。 + +#### 系统与架构兼容性 + +| 类型 | 正式支持范围 | GitHub 发行标识 | +| --- | --- | --- | +| Linux x64 | Intel/AMD 64 位 VPS | `linux-amd64` | +| Linux x86 | Intel/AMD 32 位系统 | `linux-386` | +| Linux ARM64 | AArch64、ARMv8 VPS/开发板 | `linux-arm64` | +| Linux ARM32 | ARMv7 设备 | `linux-armv7` | +| Linux 发行版 | Debian、Ubuntu、CentOS、RHEL、Rocky、AlmaLinux、Fedora、Oracle Linux、Amazon Linux、Alpine | 使用同一正式核心 | +| Docker | Linux 主机上的 amd64、386、arm64、arm/v7 | GHCR 多架构镜像 | + +> AimiliVPN 依赖 Linux 的 TUN、OpenVPN、iptables 和策略路由,因此不发布虚假的 Windows/macOS 原生兼容包。Windows 或 macOS 只能作为代理客户端使用,不能直接运行完整网关;Docker Desktop 同样不等同于具备宿主机 TUN 能力的 Linux 服务器。 + +项目由纯 Python 标准库组成,不需要为 CPU 编译不同的 Python 二进制。GitHub Actions 会为每种架构生成经过相同测试的正式发行包,并实际构建对应架构的 Docker 镜像。 + +--- + ### 🌟 VPS 优选推荐:跑 AimiliVPN 更稳更省心 [![BandwagonHost 顶级三网优化](https://img.shields.io/badge/BandwagonHost-%E9%A1%B6%E7%BA%A7%E4%B8%89%E7%BD%91%E4%BC%98%E5%8C%96-red?style=for-the-badge)](https://bandwagonhost.com/aff.php?aff=81790) [![RackNerd 6000GB 流量](https://img.shields.io/badge/RackNerd-6000GB%2F%E6%9C%88%20%E5%A4%A7%E6%B5%81%E9%87%8F-blue?style=for-the-badge)](https://my.racknerd.com/aff.php?aff=18708) @@ -30,15 +66,51 @@ AimiliVPN 是一款基于官方 VPNGate 开放协议的高性能、零依赖 VPN --- -### 🚀 一键极速部署 (支持 Debian/Ubuntu/CentOS/Alpine 等 Linux 系统) +### 🚀 安装与正式版更新 -在您的 Linux VPS 上以 root 用户执行以下对应命令: +#### 方法一:从 main 主分支一键安装(推荐) + +在 Linux VPS 上以 root 用户执行: -#### 🌟 正式稳定版本 (main 分支) ```bash bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh) ``` -> 💡 **小贴士**:部署完成后,终端会输出管理网页的专属链接(含随机安全后缀,如 `http://your_vps_ip:8787/u71e9IXp4TPx`)。在终端中输入 `ml` 命令可以随时调出交互式命令行管理菜单。 + +部署完成后,终端会输出管理网页专属链接。输入 `ml update` 时只会获取并切换到 `origin/main`,不会检测或切换任何测试分支。 + +#### 方法二:GitHub 正式发行包 + +[Releases 页面](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)提供以下文件: + +- `aimilivpn-v2.1.0-linux-amd64.tar.gz`:x64 / x86_64。 +- `aimilivpn-v2.1.0-linux-386.tar.gz`:x86 32 位。 +- `aimilivpn-v2.1.0-linux-arm64.tar.gz`:ARM64 / AArch64。 +- `aimilivpn-v2.1.0-linux-armv7.tar.gz`:ARMv7 32 位。 +- `sha256sums.txt`:所有发行包的 SHA-256 校验值。 + +#### 方法三:Docker / Docker Compose + +Docker 镜像地址:`ghcr.io/baoweise-bot/aimili-vpngate:2.1`。仓库中的 [`compose.yaml`](./compose.yaml) 已配置主机网络、`NET_ADMIN` 和 TUN 设备: + +```bash +docker compose up -d +docker logs -f aimilivpn +``` + +也可以直接运行: + +```bash +docker run -d \ + --name aimilivpn \ + --restart unless-stopped \ + --network host \ + --cap-add NET_ADMIN \ + --device /dev/net/tun:/dev/net/tun \ + -v aimilivpn-data:/data \ + ghcr.io/baoweise-bot/aimili-vpngate:2.1 +``` + +> Docker 方式只支持具备 `/dev/net/tun` 的 Linux 主机。管理页面默认端口为 `8787`,本机 HTTP/SOCKS5 代理默认端口为 `7928`。 --- @@ -85,6 +157,9 @@ bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/ma ### 🛠️ 核心功能与操作说明 * **合并操作面板**:将“更新节点”与“立即检测补齐”合并,一键触发多线程拉取与测速。 +* **正式版更新检测**:Web 顶部版本菜单可以检查 GitHub 最新稳定 Release,并提供 `main` 主分支和正式版下载入口。 +* **多国家发现范围**:节点表可实时勾选多个国家;点击“更新节点”后保存范围并影响后台周期拉取。 +* **延迟来源区分**:实测延迟正常显示,官方 Ping 回退值使用弱化样式并标注为预估。 * **网关状态面板**: - **系统诊断**:检测网关心跳及后台各个子守护线程(网页服务、VPN连接管理、出站网关服务)是否正常运行。若有脚本未运行,会提示具体的异常原因。 - **本地代理出口检测**:在网页端直接一键检测 VPS 后台对海外的实际连通状况,并回显真实的代理出站 IP 和所在地理位置。 @@ -171,7 +246,7 @@ AimiliVPN is a high-performance, zero-dependency VPN proxy gateway built entirel Run the corresponding command on your Linux VPS as root: -#### 🌟 Stable Release (main branch) +#### 🌟 V2.1 Formal Release (main branch only) ```bash bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh) ``` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..22e5587 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,18 @@ +# AimiliVPN V2.1 正式版 + +V2.1 是仅从 `main` 主分支发布的首个正式版本标志。 + +## 本次更新 + +- 节点来源按“VPNGate 官方 HTTPS -> 官方 HTTP -> GitHub Pages HTTPS -> GitHub Pages HTTP -> VPS 本地快照 -> 内置初始快照”自动回退。 +- 修复节点获取缓慢、连接断开和切换失败时误伤现有连接的问题。 +- 恢复节点延迟列,区分本机实测值与 VPNGate 官方预估值。 +- 加入国旗、实时多选国家筛选、国家范围持久化和单节点测试。 +- Web 管理端加入正式版更新检测,只检查 GitHub 最新稳定 Release,并只保留 `main` 主分支入口。 +- `install.sh` 和 `ml update` 统一只更新 `origin/main`。 +- GitHub Release 提供 Linux `amd64`、`386`、`arm64`、`armv7` 发行包与 SHA-256 校验文件。 +- GHCR 提供相同四种架构的 Docker 镜像。 + +## 兼容范围 + +应用依赖 Linux TUN、OpenVPN、iptables 和策略路由,因此正式支持 Linux 主机。Docker 也必须运行在具备 `/dev/net/tun` 的 Linux 主机上,并授予 `NET_ADMIN` 能力。 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7ec1d6d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..32008fc --- /dev/null +++ b/compose.yaml @@ -0,0 +1,22 @@ +services: + aimilivpn: + image: ghcr.io/baoweise-bot/aimili-vpngate:2.1 + container_name: aimilivpn + network_mode: host + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + environment: + VPNGATE_DATA_DIR: /data + UI_HOST: "0.0.0.0" + UI_PORT: "8787" + LOCAL_PROXY_HOST: "127.0.0.1" + LOCAL_PROXY_PORT: "7928" + volumes: + - aimilivpn-data:/data + init: true + restart: unless-stopped + +volumes: + aimilivpn-data: diff --git a/install.sh b/install.sh index 3e083cd..359fcf9 100644 --- a/install.sh +++ b/install.sh @@ -82,15 +82,7 @@ fi # 4. Clone or pull the repository INSTALL_DIR="/opt/aimilivpn" -# 默认部署分支(在 bate 分支设为 bate;在 main 分支设为 main) -DEFAULT_DEPLOY_BRANCH="main" - -# 自动检测本地已安装版本当前所在的分支 -CURRENT_BRANCH="" -if [ -d "${INSTALL_DIR}/.git" ]; then - CURRENT_BRANCH=$(cd "${INSTALL_DIR}" && git rev-parse --abbrev-ref HEAD 2>/dev/null) -fi -DEPLOY_BRANCH="${CURRENT_BRANCH:-$DEFAULT_DEPLOY_BRANCH}" +DEPLOY_BRANCH="main" echo -e "\n${YELLOW}[2/4] 正在从 GitHub 部署源代码到 ${INSTALL_DIR} (目标分支: ${DEPLOY_BRANCH})...${PLAIN}" if [ -f "${INSTALL_DIR}/.local_dev" ]; then @@ -99,8 +91,8 @@ else if [ -d "${INSTALL_DIR}" ]; then echo -e " -> 目录 ${INSTALL_DIR} 已存在,正在更新并强制覆盖本地源码..." cd "${INSTALL_DIR}" - git fetch --all || true - git checkout "${DEPLOY_BRANCH}" || git checkout -b "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true + git fetch origin "${DEPLOY_BRANCH}" || true + git checkout -B "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true echo -e " -> 正在强制重置本地源码至 origin/${DEPLOY_BRANCH} ..." if git reset --hard "origin/${DEPLOY_BRANCH}"; then echo -e "${GREEN} -> 源码更新成功!${PLAIN}" @@ -113,13 +105,14 @@ else fi else echo -e " -> 正在克隆 GitHub 仓库 ${GITHUB_URL} (分支: ${DEPLOY_BRANCH}) ..." - if git clone -b "${DEPLOY_BRANCH}" "${GITHUB_URL}" "${INSTALL_DIR}"; then + if git clone --branch "${DEPLOY_BRANCH}" --single-branch "${GITHUB_URL}" "${INSTALL_DIR}"; then echo -e "${GREEN} -> 克隆成功!${PLAIN}" else echo -e " -> 尝试默认克隆..." if git clone "${GITHUB_URL}" "${INSTALL_DIR}"; then cd "${INSTALL_DIR}" - git checkout "${DEPLOY_BRANCH}" || git checkout -b "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true + git fetch origin "${DEPLOY_BRANCH}" + git checkout -B "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" echo -e "${GREEN} -> 克隆成功!${PLAIN}" else echo -e "${RED} -> 错误: 无法克隆仓库 ${GITHUB_URL},请检查网络!${PLAIN}" @@ -516,7 +509,7 @@ def show_logs(): time.sleep(2) def update_service(): - print("正在获取远程更新并检测版本...", flush=True) + print("正在从 GitHub main 主分支检测正式版更新...", flush=True) if os.path.exists(INSTALL_DIR): try: os.chdir(INSTALL_DIR) @@ -525,20 +518,13 @@ def update_service(): time.sleep(3) return - # Fetch remote origin updates - subprocess.run(["git", "fetch", "--all"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - - # Detect remote branch (prefer current local branch, fallback to origin/main or origin/master) - curr = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True) - branch = curr.stdout.strip() if curr.returncode == 0 else "" - if not branch or branch == "HEAD": - branch = "main" - for b in ["main", "master"]: - chk = subprocess.run(["git", "rev-parse", "--verify", f"origin/{b}"], capture_output=True, text=True) - if chk.returncode == 0: - branch = b - break - + branch = "main" + subprocess.run( + ["git", "fetch", "origin", branch], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) local_commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip() remote_commit = subprocess.run(["git", "rev-parse", f"origin/{branch}"], capture_output=True, text=True).stdout.strip() @@ -557,7 +543,8 @@ def update_service(): time.sleep(1.5) return - print(f"\n正在强制重置本地代码至 origin/{branch} ...", flush=True) + print(f"\n正在切换并重置到正式版 origin/{branch} ...", flush=True) + subprocess.run(["git", "checkout", "-B", branch, f"origin/{branch}"], check=True) subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True) # Clean up python cache files diff --git a/scripts/build_release_archives.py b/scripts/build_release_archives.py new file mode 100644 index 0000000..ccba93f --- /dev/null +++ b/scripts/build_release_archives.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import tarfile +import tempfile +from pathlib import Path + + +TARGETS = { + "amd64": {"cpu": "x86_64 / x64", "docker_platform": "linux/amd64"}, + "386": {"cpu": "x86 / i386 32-bit", "docker_platform": "linux/386"}, + "arm64": {"cpu": "ARM64 / AArch64", "docker_platform": "linux/arm64"}, + "armv7": {"cpu": "ARMv7 32-bit", "docker_platform": "linux/arm/v7"}, +} + +RELEASE_FILES = [ + "VERSION", + "README.md", + "RELEASE_NOTES.md", + "LICENSE", + "install.sh", + "vpngate_manager.py", + "vpn_utils.py", + "proxy_server.py", + "snapshot_utils.py", + "Dockerfile", + "compose.yaml", +] + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_archives(root: Path, output_dir: Path) -> list[Path]: + version = (root / "VERSION").read_text(encoding="utf-8").strip() + version_parts = version.split(".") + if len(version_parts) not in (2, 3) or any(not part.isdigit() for part in version_parts): + raise ValueError("VERSION 必须是点分隔的数字版本号") + + missing = [name for name in RELEASE_FILES if not (root / name).is_file()] + if missing: + raise FileNotFoundError(f"发行文件缺失: {', '.join(missing)}") + if not (root / "mirror").is_dir(): + raise FileNotFoundError("发行文件缺失: mirror") + + output_dir.mkdir(parents=True, exist_ok=True) + archives: list[Path] = [] + with tempfile.TemporaryDirectory(prefix="aimilivpn-release-") as temp_name: + temp_root = Path(temp_name) + for architecture, metadata in TARGETS.items(): + package_name = f"aimilivpn-v{version}-linux-{architecture}" + package_root = temp_root / package_name + package_root.mkdir() + + for name in RELEASE_FILES: + shutil.copy2(root / name, package_root / name) + shutil.copytree(root / "mirror", package_root / "mirror") + build_info = { + "product": "AimiliVPN", + "version": version, + "release": f"V{'.'.join(version.split('.')[:2])} 正式版", + "branch": "main", + "operating_system": "Linux", + "architecture": architecture, + **metadata, + "supported_distributions": [ + "Debian", + "Ubuntu", + "CentOS", + "RHEL", + "Rocky Linux", + "AlmaLinux", + "Fedora", + "Oracle Linux", + "Amazon Linux", + "Alpine Linux", + ], + } + (package_root / "BUILD_INFO.json").write_text( + json.dumps(build_info, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + archive_path = output_dir / f"{package_name}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(package_root, arcname=package_name) + archives.append(archive_path) + + checksum_lines = [f"{sha256(path)} {path.name}" for path in archives] + checksum_path = output_dir / "sha256sums.txt" + checksum_path.write_text("\n".join(checksum_lines) + "\n", encoding="ascii") + return archives + + +def main() -> int: + parser = argparse.ArgumentParser(description="构建 AimiliVPN Linux 多架构发行包") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--output-dir", type=Path, default=Path("dist")) + args = parser.parse_args() + + archives = build_archives(args.root.resolve(), args.output_dir.resolve()) + for archive in archives: + print(archive) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_manager_logic.py b/tests/test_manager_logic.py index c328086..ab361f2 100644 --- a/tests/test_manager_logic.py +++ b/tests/test_manager_logic.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import json import os import tempfile import threading @@ -393,6 +394,59 @@ class ManagerLogicTests(unittest.TestCase): self.assertIn('class="country-option-input"', manager.INDEX_HTML) self.assertIn('${testBtn}', manager.INDEX_HTML) + def test_web_update_controls_only_expose_stable_main_channel(self) -> None: + self.assertEqual("2.1.0", manager.APP_VERSION) + self.assertEqual("V2.1 正式版", manager.APP_VERSION_LABEL) + self.assertIn("检测更新", manager.INDEX_HTML) + self.assertIn("/api/check_update", manager.INDEX_HTML) + self.assertIn("/tree/main", manager.INDEX_HTML) + self.assertIn("/releases/latest", manager.INDEX_HTML) + self.assertNotIn("/tree/bate", manager.INDEX_HTML) + self.assertNotIn(">测试版<", manager.INDEX_HTML) + + def test_installer_updates_only_from_main(self) -> None: + install_text = (manager.ROOT_DIR / "install.sh").read_text(encoding="utf-8") + + self.assertIn('DEPLOY_BRANCH="main"', install_text) + self.assertIn('branch = "main"', install_text) + self.assertNotIn("CURRENT_BRANCH", install_text) + self.assertNotIn("origin/master", install_text) + self.assertNotIn("bate", install_text.lower()) + + def test_latest_release_check_ignores_non_version_name_text(self) -> None: + release = { + "tag_name": "v2.2.0", + "name": "AimiliVPN V2.2 正式版", + "published_at": "2026-09-01T00:00:00Z", + "draft": False, + "prerelease": False, + } + with mock.patch.object(manager, "fetch_api_text", return_value=json.dumps(release)) as fetch_mock: + result = manager.check_latest_release() + + self.assertTrue(result["ok"]) + self.assertTrue(result["update_available"]) + self.assertEqual("2.2.0", result["latest_version"]) + self.assertEqual("v2.2.0", result["latest_tag"]) + self.assertEqual( + "https://github.com/baoweise-bot/aimili-vpngate/releases/tag/v2.2.0", + result["release_url"], + ) + fetch_mock.assert_called_once_with(manager.GITHUB_LATEST_RELEASE_API, True) + + def test_latest_release_check_reports_current_formal_version(self) -> None: + release = { + "tag_name": "v2.1.0", + "name": "AimiliVPN V2.1 正式版", + "draft": False, + "prerelease": False, + } + with mock.patch.object(manager, "fetch_api_text", return_value=json.dumps(release)): + result = manager.check_latest_release() + + self.assertFalse(result["update_available"]) + self.assertEqual("V2.1 正式版", result["current_version_label"]) + def test_fetch_uses_github_mirror_after_official_sources(self) -> None: csv_text = valid_snapshot() diff --git a/vpngate_manager.py b/vpngate_manager.py index f9894cc..fbf6925 100644 --- a/vpngate_manager.py +++ b/vpngate_manager.py @@ -119,6 +119,18 @@ UI_PORT = env_int("UI_PORT", 8787, 1, 65535) INVALID_BACKOFF_SECONDS = env_int("INVALID_BACKOFF_SECONDS", 30 * 60, 1) ROOT_DIR = Path(sys.executable).resolve().parent if globals().get("__compiled__") else Path(__file__).resolve().parent +DEFAULT_APP_VERSION = "2.1.0" +try: + _version_text = (ROOT_DIR / "VERSION").read_text(encoding="utf-8").strip() +except OSError: + _version_text = DEFAULT_APP_VERSION +APP_VERSION = _version_text if re.fullmatch(r"\d+\.\d+(?:\.\d+)?", _version_text) else DEFAULT_APP_VERSION +APP_VERSION_SHORT = ".".join(APP_VERSION.split(".")[:2]) +APP_VERSION_LABEL = f"V{APP_VERSION_SHORT} 正式版" +GITHUB_REPOSITORY = "baoweise-bot/aimili-vpngate" +GITHUB_REPOSITORY_URL = f"https://github.com/{GITHUB_REPOSITORY}" +GITHUB_MAIN_BRANCH_URL = f"{GITHUB_REPOSITORY_URL}/tree/main" +GITHUB_LATEST_RELEASE_API = f"https://api.github.com/repos/{GITHUB_REPOSITORY}/releases/latest" DATA_DIR = Path(os.environ["VPNGATE_DATA_DIR"]).resolve() if os.environ.get("VPNGATE_DATA_DIR") else ROOT_DIR / "vpngate_data" CONFIG_DIR = DATA_DIR / "configs" NODES_FILE = DATA_DIR / "nodes.json" @@ -415,6 +427,8 @@ def get_state() -> dict[str, Any]: state.setdefault("last_check_message", "") state.setdefault("pending_node_id", "") state.setdefault("blacklisted_nodes", 0) + state["app_version"] = APP_VERSION + state["app_version_label"] = APP_VERSION_LABEL # Pre-populate settings inputs in UI ui_cfg = load_ui_config() @@ -696,7 +710,7 @@ def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str: request = urllib.request.Request( url, headers={ - "User-Agent": "Mozilla/5.0 vpngate-openvpn-manager/2.0", + "User-Agent": f"Mozilla/5.0 AimiliVPN/{APP_VERSION}", "Accept": "text/plain,*/*", }, ) @@ -722,6 +736,37 @@ def fetch_api_text(url: str | None = None, use_ssl_verify: bool = True) -> str: with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS) as response: return read_limited(response).decode("utf-8", errors="replace") +def parse_release_version(value: Any) -> tuple[int, int, int]: + match = re.search(r"(?i)(?:^|[^a-z0-9])v?(\d+)(?:\.(\d+))?(?:\.(\d+))?", str(value or "").strip()) + if not match: + raise ValueError("GitHub Release 版本号格式无效") + return tuple(int(part or 0) for part in match.groups()) + +def check_latest_release() -> dict[str, Any]: + payload = json.loads(fetch_api_text(GITHUB_LATEST_RELEASE_API, True)) + if not isinstance(payload, dict): + raise ValueError("GitHub Release API 返回格式无效") + if payload.get("draft") or payload.get("prerelease"): + raise ValueError("GitHub 最新版本不是正式版") + + latest_tag = str(payload.get("tag_name") or "").strip() + latest_version = parse_release_version(latest_tag) + current_version = parse_release_version(APP_VERSION) + release_url = f"{GITHUB_REPOSITORY_URL}/releases/tag/{urllib.parse.quote(latest_tag, safe='')}" + + return { + "ok": True, + "current_version": APP_VERSION, + "current_version_label": APP_VERSION_LABEL, + "latest_version": ".".join(str(part) for part in latest_version), + "latest_tag": latest_tag, + "latest_name": str(payload.get("name") or latest_tag), + "published_at": str(payload.get("published_at") or ""), + "update_available": latest_version > current_version, + "release_url": release_url, + "main_branch_url": GITHUB_MAIN_BRANCH_URL, + } + def is_certificate_verification_error(exc: BaseException) -> bool: import ssl @@ -3613,6 +3658,12 @@ INDEX_HTML = r""" width: 100%; flex: 1; } + #github_dropdown { + left: 0; + right: auto; + width: min(280px, calc(100vw - 40px)); + min-width: 0; + } main { padding: 16px 20px; } @@ -3646,19 +3697,80 @@ INDEX_HTML = r""" backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } - .dropdown-content a { + .dropdown-content a, + .dropdown-content button { display: flex; align-items: center; gap: 8px; + width: 100%; padding: 10px 16px; color: var(--text-primary); text-decoration: none; + text-align: left; font-size: 13px; font-weight: 500; + font-family: inherit; + border: 0; + background: transparent; + box-sizing: border-box; + cursor: pointer; transition: background 0.2s; } - .dropdown-content a:hover { + .dropdown-content a:hover, + .dropdown-content button:hover:not(:disabled), + .dropdown-content a:focus-visible, + .dropdown-content button:focus-visible { background: rgba(255,255,255,0.08); + outline: none; + } + .dropdown-content button:disabled { + opacity: 0.55; + cursor: wait; + } + .github-dropdown { + min-width: 250px; + padding: 6px; + } + .version-current { + padding: 9px 10px 10px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border-color); + } + .version-current-label { + color: var(--text-primary); + font-size: 13px; + font-weight: 700; + } + .version-current-meta { + margin-top: 3px; + color: var(--text-secondary); + font-size: 11px; + } + .update-check-status { + min-height: 34px; + margin: 6px 6px 0; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; + } + .update-check-status.available { + border-color: rgba(245, 158, 11, 0.35); + color: #fbbf24; + background: rgba(245, 158, 11, 0.08); + } + .update-check-status.current { + border-color: rgba(16, 185, 129, 0.3); + color: #34d399; + background: rgba(16, 185, 129, 0.08); + } + .update-check-status.error { + border-color: rgba(244, 63, 94, 0.3); + color: #fb7185; + background: rgba(244, 63, 94, 0.08); } /* Modal styles */ @@ -3795,14 +3907,23 @@ INDEX_HTML = r"""