Release AimiliVPN V2.1 stable channel

This commit is contained in:
Aimili
2026-08-26 23:13:30 +08:00
parent c7aa86c86c
commit a9cf220500
11 changed files with 658 additions and 45 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.github
__pycache__
*.pyc
vpngate_data
dist
build
tests
scratch
_site
+95
View File
@@ -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
+46
View File
@@ -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"]
+80 -5
View File
@@ -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)
```
+18
View File
@@ -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` 能力。
+1
View File
@@ -0,0 +1 @@
2.1.0
+22
View File
@@ -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:
+16 -29
View File
@@ -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
+117
View File
@@ -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())
+54
View File
@@ -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()
+199 -11
View File
@@ -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"""<!doctype html>
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"""<!doctype html>
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"""<!doctype html>
<div class="btn-group">
<div class="dropdown">
<button id="github_btn" class="btn-primary" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<button id="github_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="github_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" style="vertical-align: middle; margin-right: 4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
GITHUB
<span id="github_version_label">V2.1 正式版</span>
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
</button>
<div id="github_dropdown" class="dropdown-content">
<a href="https://github.com/baoweise-bot/aimili-vpngate" target="_blank">正式版</a>
<a href="https://github.com/baoweise-bot/aimili-vpngate/tree/bate" target="_blank">测试</a>
<div id="github_dropdown" class="dropdown-content github-dropdown">
<div class="version-current">
<div id="current_version_label" class="version-current-label">V2.1 正式</div>
<div class="version-current-meta">唯一更新通道main 主分支</div>
</div>
<button id="check_update_btn" type="button" onclick="checkForUpdate(event)">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" style="width:14px; height:14px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 8H18.5" /></svg>
检测更新
</button>
<a href="https://github.com/baoweise-bot/aimili-vpngate/tree/main" target="_blank" rel="noopener noreferrer">GitHub main 主分支</a>
<a id="latest_release_link" href="https://github.com/baoweise-bot/aimili-vpngate/releases/latest" target="_blank" rel="noopener noreferrer">下载最新正式版</a>
<div id="update_check_status" class="update-check-status" role="status" aria-live="polite">点击检测更新查询 GitHub 最新正式版</div>
</div>
</div>
<a href="https://t.me/arestemple" target="_blank" class="btn-telegram">
@@ -3814,7 +3935,7 @@ INDEX_HTML = r"""<!doctype html>
更新节点
</button>
<div class="dropdown">
<button id="admin_btn" class="btn-primary" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<button id="admin_btn" class="btn-primary" type="button" aria-expanded="false" aria-controls="admin_dropdown" style="background: rgba(255, 255, 255, 0.08); border: 1px solid var(--border-color); color: var(--text-primary);">
<svg xmlns="http://www.w3.org/2000/svg" style="width:16px; height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
管理员
<svg xmlns="http://www.w3.org/2000/svg" style="width:12px; height:12px; margin-left: 2px;" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
@@ -4510,6 +4631,10 @@ function stableSortNodes() {
}
function render(){
const versionLabel = state.app_version_label || "V2.1 正式版";
if ($("github_version_label")) $("github_version_label").textContent = versionLabel;
if ($("current_version_label")) $("current_version_label").textContent = versionLabel;
const activeNodeId = state.active_openvpn_node_id;
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
@@ -5022,12 +5147,48 @@ const adminDropdown = $("admin_dropdown");
const githubBtn = $("github_btn");
const githubDropdown = $("github_dropdown");
async function checkForUpdate(event) {
if (event) event.stopPropagation();
const button = $("check_update_btn");
const statusBox = $("update_check_status");
const releaseLink = $("latest_release_link");
if (!button || !statusBox) return;
button.disabled = true;
statusBox.className = "update-check-status";
statusBox.textContent = "正在连接 GitHub 检查最新正式版...";
try {
const response = await fetch("./api/check_update", { cache: "no-store" });
const result = await response.json();
if (!response.ok || !result.ok) {
throw new Error(result.error || "更新检查失败");
}
if (releaseLink && result.release_url) releaseLink.href = result.release_url;
if (result.update_available) {
statusBox.className = "update-check-status available";
statusBox.textContent = `发现正式版 ${result.latest_tag}当前为 ${result.current_version_label}请打开正式版下载页更新`;
} else {
statusBox.className = "update-check-status current";
statusBox.textContent = `当前 ${result.current_version_label} 已是最新正式版`;
}
} catch (error) {
statusBox.className = "update-check-status error";
statusBox.textContent = error.message || "无法连接 GitHub,请稍后重试。";
} finally {
button.disabled = false;
}
}
if (adminBtn && adminDropdown) {
adminBtn.onclick = (e) => {
e.stopPropagation();
const isShow = adminDropdown.style.display === "block";
adminDropdown.style.display = isShow ? "none" : "block";
if (githubDropdown) githubDropdown.style.display = "none";
adminBtn.setAttribute("aria-expanded", isShow ? "false" : "true");
if (githubDropdown) {
githubDropdown.style.display = "none";
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
}
};
}
@@ -5036,13 +5197,32 @@ if (githubBtn && githubDropdown) {
e.stopPropagation();
const isShow = githubDropdown.style.display === "block";
githubDropdown.style.display = isShow ? "none" : "block";
if (adminDropdown) adminDropdown.style.display = "none";
githubBtn.setAttribute("aria-expanded", isShow ? "false" : "true");
if (adminDropdown) {
adminDropdown.style.display = "none";
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
}
};
githubDropdown.onclick = event => event.stopPropagation();
}
document.addEventListener("click", () => {
if (adminDropdown) adminDropdown.style.display = "none";
if (githubDropdown) githubDropdown.style.display = "none";
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
});
document.addEventListener("keydown", event => {
if (event.key !== "Escape") return;
const githubWasOpen = githubBtn && githubBtn.getAttribute("aria-expanded") === "true";
const adminWasOpen = adminBtn && adminBtn.getAttribute("aria-expanded") === "true";
if (adminDropdown) adminDropdown.style.display = "none";
if (githubDropdown) githubDropdown.style.display = "none";
if (adminBtn) adminBtn.setAttribute("aria-expanded", "false");
if (githubBtn) githubBtn.setAttribute("aria-expanded", "false");
if (githubWasOpen && githubBtn) githubBtn.focus();
else if (adminWasOpen && adminBtn) adminBtn.focus();
});
let showFavoritesOnly = false;
@@ -6017,6 +6197,14 @@ class Handler(BaseHTTPRequestHandler):
del stripped["config_text"]
stripped_nodes.append(stripped)
self.send_json({"nodes": stripped_nodes, "state": get_state()})
elif effective_path == "/api/check_update":
try:
self.send_json(check_latest_release())
except Exception as exc:
self.send_json(
{"ok": False, "error": f"无法检查 GitHub 正式版更新: {exc}"},
HTTPStatus.BAD_GATEWAY,
)
elif effective_path.startswith("/configs/"):
filename = urllib.parse.unquote(effective_path.removeprefix("/configs/"))
with lock: