mirror of
https://github.com/baoweise-bot/aimili-vpngate.git
synced 2026-09-08 17:26:46 +08:00
Release AimiliVPN V2.1 stable channel
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
vpngate_data
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
tests
|
||||||
|
scratch
|
||||||
|
_site
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# AimiliVPN 🌐
|
# AimiliVPN 🌐
|
||||||
|
|
||||||
|
[](https://github.com/baoweise-bot/aimili-vpngate/releases/latest)
|
||||||
|
[](https://github.com/baoweise-bot/aimili-vpngate/tree/main)
|
||||||
|
[](https://github.com/baoweise-bot/aimili-vpngate/pkgs/container/aimili-vpngate)
|
||||||
|
|
||||||
Bilingual: [中文](#中文) | [English](#english)
|
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 更稳更省心
|
### 🌟 VPS 优选推荐:跑 AimiliVPN 更稳更省心
|
||||||
[](https://bandwagonhost.com/aff.php?aff=81790)
|
[](https://bandwagonhost.com/aff.php?aff=81790)
|
||||||
[](https://my.racknerd.com/aff.php?aff=18708)
|
[](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
|
||||||
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
|
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连接管理、出站网关服务)是否正常运行。若有脚本未运行,会提示具体的异常原因。
|
- **系统诊断**:检测网关心跳及后台各个子守护线程(网页服务、VPN连接管理、出站网关服务)是否正常运行。若有脚本未运行,会提示具体的异常原因。
|
||||||
- **本地代理出口检测**:在网页端直接一键检测 VPS 后台对海外的实际连通状况,并回显真实的代理出站 IP 和所在地理位置。
|
- **本地代理出口检测**:在网页端直接一键检测 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:
|
Run the corresponding command on your Linux VPS as root:
|
||||||
|
|
||||||
#### 🌟 Stable Release (main branch)
|
#### 🌟 V2.1 Formal Release (main branch only)
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
|
bash <(curl -Ls https://raw.githubusercontent.com/baoweise-bot/aimili-vpngate/main/install.sh)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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` 能力。
|
||||||
@@ -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:
|
||||||
+15
-28
@@ -82,15 +82,7 @@ fi
|
|||||||
|
|
||||||
# 4. Clone or pull the repository
|
# 4. Clone or pull the repository
|
||||||
INSTALL_DIR="/opt/aimilivpn"
|
INSTALL_DIR="/opt/aimilivpn"
|
||||||
# 默认部署分支(在 bate 分支设为 bate;在 main 分支设为 main)
|
DEPLOY_BRANCH="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}"
|
|
||||||
|
|
||||||
echo -e "\n${YELLOW}[2/4] 正在从 GitHub 部署源代码到 ${INSTALL_DIR} (目标分支: ${DEPLOY_BRANCH})...${PLAIN}"
|
echo -e "\n${YELLOW}[2/4] 正在从 GitHub 部署源代码到 ${INSTALL_DIR} (目标分支: ${DEPLOY_BRANCH})...${PLAIN}"
|
||||||
if [ -f "${INSTALL_DIR}/.local_dev" ]; then
|
if [ -f "${INSTALL_DIR}/.local_dev" ]; then
|
||||||
@@ -99,8 +91,8 @@ else
|
|||||||
if [ -d "${INSTALL_DIR}" ]; then
|
if [ -d "${INSTALL_DIR}" ]; then
|
||||||
echo -e " -> 目录 ${INSTALL_DIR} 已存在,正在更新并强制覆盖本地源码..."
|
echo -e " -> 目录 ${INSTALL_DIR} 已存在,正在更新并强制覆盖本地源码..."
|
||||||
cd "${INSTALL_DIR}"
|
cd "${INSTALL_DIR}"
|
||||||
git fetch --all || true
|
git fetch origin "${DEPLOY_BRANCH}" || true
|
||||||
git checkout "${DEPLOY_BRANCH}" || git checkout -b "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true
|
git checkout -B "${DEPLOY_BRANCH}" "origin/${DEPLOY_BRANCH}" || true
|
||||||
echo -e " -> 正在强制重置本地源码至 origin/${DEPLOY_BRANCH} ..."
|
echo -e " -> 正在强制重置本地源码至 origin/${DEPLOY_BRANCH} ..."
|
||||||
if git reset --hard "origin/${DEPLOY_BRANCH}"; then
|
if git reset --hard "origin/${DEPLOY_BRANCH}"; then
|
||||||
echo -e "${GREEN} -> 源码更新成功!${PLAIN}"
|
echo -e "${GREEN} -> 源码更新成功!${PLAIN}"
|
||||||
@@ -113,13 +105,14 @@ else
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e " -> 正在克隆 GitHub 仓库 ${GITHUB_URL} (分支: ${DEPLOY_BRANCH}) ..."
|
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}"
|
echo -e "${GREEN} -> 克隆成功!${PLAIN}"
|
||||||
else
|
else
|
||||||
echo -e " -> 尝试默认克隆..."
|
echo -e " -> 尝试默认克隆..."
|
||||||
if git clone "${GITHUB_URL}" "${INSTALL_DIR}"; then
|
if git clone "${GITHUB_URL}" "${INSTALL_DIR}"; then
|
||||||
cd "${INSTALL_DIR}"
|
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}"
|
echo -e "${GREEN} -> 克隆成功!${PLAIN}"
|
||||||
else
|
else
|
||||||
echo -e "${RED} -> 错误: 无法克隆仓库 ${GITHUB_URL},请检查网络!${PLAIN}"
|
echo -e "${RED} -> 错误: 无法克隆仓库 ${GITHUB_URL},请检查网络!${PLAIN}"
|
||||||
@@ -516,7 +509,7 @@ def show_logs():
|
|||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
def update_service():
|
def update_service():
|
||||||
print("正在获取远程更新并检测版本...", flush=True)
|
print("正在从 GitHub main 主分支检测正式版更新...", flush=True)
|
||||||
if os.path.exists(INSTALL_DIR):
|
if os.path.exists(INSTALL_DIR):
|
||||||
try:
|
try:
|
||||||
os.chdir(INSTALL_DIR)
|
os.chdir(INSTALL_DIR)
|
||||||
@@ -525,20 +518,13 @@ def update_service():
|
|||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
return
|
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"
|
branch = "main"
|
||||||
for b in ["main", "master"]:
|
subprocess.run(
|
||||||
chk = subprocess.run(["git", "rev-parse", "--verify", f"origin/{b}"], capture_output=True, text=True)
|
["git", "fetch", "origin", branch],
|
||||||
if chk.returncode == 0:
|
check=True,
|
||||||
branch = b
|
stdout=subprocess.DEVNULL,
|
||||||
break
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
local_commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
|
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()
|
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)
|
time.sleep(1.5)
|
||||||
return
|
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)
|
subprocess.run(["git", "reset", "--hard", f"origin/{branch}"], check=True)
|
||||||
|
|
||||||
# Clean up python cache files
|
# Clean up python cache files
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
@@ -393,6 +394,59 @@ class ManagerLogicTests(unittest.TestCase):
|
|||||||
self.assertIn('class="country-option-input"', manager.INDEX_HTML)
|
self.assertIn('class="country-option-input"', manager.INDEX_HTML)
|
||||||
self.assertIn('${testBtn}', 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:
|
def test_fetch_uses_github_mirror_after_official_sources(self) -> None:
|
||||||
csv_text = valid_snapshot()
|
csv_text = valid_snapshot()
|
||||||
|
|
||||||
|
|||||||
+199
-11
@@ -119,6 +119,18 @@ UI_PORT = env_int("UI_PORT", 8787, 1, 65535)
|
|||||||
INVALID_BACKOFF_SECONDS = env_int("INVALID_BACKOFF_SECONDS", 30 * 60, 1)
|
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
|
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"
|
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"
|
CONFIG_DIR = DATA_DIR / "configs"
|
||||||
NODES_FILE = DATA_DIR / "nodes.json"
|
NODES_FILE = DATA_DIR / "nodes.json"
|
||||||
@@ -415,6 +427,8 @@ def get_state() -> dict[str, Any]:
|
|||||||
state.setdefault("last_check_message", "")
|
state.setdefault("last_check_message", "")
|
||||||
state.setdefault("pending_node_id", "")
|
state.setdefault("pending_node_id", "")
|
||||||
state.setdefault("blacklisted_nodes", 0)
|
state.setdefault("blacklisted_nodes", 0)
|
||||||
|
state["app_version"] = APP_VERSION
|
||||||
|
state["app_version_label"] = APP_VERSION_LABEL
|
||||||
|
|
||||||
# Pre-populate settings inputs in UI
|
# Pre-populate settings inputs in UI
|
||||||
ui_cfg = load_ui_config()
|
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(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": "Mozilla/5.0 vpngate-openvpn-manager/2.0",
|
"User-Agent": f"Mozilla/5.0 AimiliVPN/{APP_VERSION}",
|
||||||
"Accept": "text/plain,*/*",
|
"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:
|
with urllib.request.urlopen(request, timeout=API_FETCH_TIMEOUT_SECONDS) as response:
|
||||||
return read_limited(response).decode("utf-8", errors="replace")
|
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:
|
def is_certificate_verification_error(exc: BaseException) -> bool:
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
@@ -3613,6 +3658,12 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
#github_dropdown {
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
width: min(280px, calc(100vw - 40px));
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
main {
|
main {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
}
|
}
|
||||||
@@ -3646,19 +3697,80 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
-webkit-backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
.dropdown-content a {
|
.dropdown-content a,
|
||||||
|
.dropdown-content button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
text-align: left;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
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);
|
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 */
|
/* Modal styles */
|
||||||
@@ -3795,14 +3907,23 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
|
||||||
<div class="dropdown">
|
<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>
|
<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>
|
<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>
|
</button>
|
||||||
<div id="github_dropdown" class="dropdown-content">
|
<div id="github_dropdown" class="dropdown-content github-dropdown">
|
||||||
<a href="https://github.com/baoweise-bot/aimili-vpngate" target="_blank">正式版</a>
|
<div class="version-current">
|
||||||
<a href="https://github.com/baoweise-bot/aimili-vpngate/tree/bate" target="_blank">测试版</a>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<a href="https://t.me/arestemple" target="_blank" class="btn-telegram">
|
<a href="https://t.me/arestemple" target="_blank" class="btn-telegram">
|
||||||
@@ -3814,7 +3935,7 @@ INDEX_HTML = r"""<!doctype html>
|
|||||||
更新节点
|
更新节点
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown">
|
<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: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>
|
<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(){
|
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 activeNodeId = state.active_openvpn_node_id;
|
||||||
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
|
const activeNode = nodes.find(n => n && (n.active || n.id === activeNodeId));
|
||||||
|
|
||||||
@@ -5022,12 +5147,48 @@ const adminDropdown = $("admin_dropdown");
|
|||||||
const githubBtn = $("github_btn");
|
const githubBtn = $("github_btn");
|
||||||
const githubDropdown = $("github_dropdown");
|
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) {
|
if (adminBtn && adminDropdown) {
|
||||||
adminBtn.onclick = (e) => {
|
adminBtn.onclick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const isShow = adminDropdown.style.display === "block";
|
const isShow = adminDropdown.style.display === "block";
|
||||||
adminDropdown.style.display = isShow ? "none" : "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();
|
e.stopPropagation();
|
||||||
const isShow = githubDropdown.style.display === "block";
|
const isShow = githubDropdown.style.display === "block";
|
||||||
githubDropdown.style.display = isShow ? "none" : "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", () => {
|
document.addEventListener("click", () => {
|
||||||
if (adminDropdown) adminDropdown.style.display = "none";
|
if (adminDropdown) adminDropdown.style.display = "none";
|
||||||
if (githubDropdown) githubDropdown.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;
|
let showFavoritesOnly = false;
|
||||||
@@ -6017,6 +6197,14 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
del stripped["config_text"]
|
del stripped["config_text"]
|
||||||
stripped_nodes.append(stripped)
|
stripped_nodes.append(stripped)
|
||||||
self.send_json({"nodes": stripped_nodes, "state": get_state()})
|
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/"):
|
elif effective_path.startswith("/configs/"):
|
||||||
filename = urllib.parse.unquote(effective_path.removeprefix("/configs/"))
|
filename = urllib.parse.unquote(effective_path.removeprefix("/configs/"))
|
||||||
with lock:
|
with lock:
|
||||||
|
|||||||
Reference in New Issue
Block a user