From 0e79f74184548481f9fb363bc14f1096e439ad43 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 23 Jun 2026 00:44:35 +0000 Subject: [PATCH] import upstream GUKO 2026-06-23 --- .dockerignore | 13 + .env.example | 44 + .github/ISSUE_TEMPLATE/bug_report.yml | 25 + .github/ISSUE_TEMPLATE/feature_request.yml | 17 + .github/workflows/ci.yml | 27 + .github/workflows/docker-publish.yml | 45 + .github/workflows/release.yml | 66 + .gitignore | 14 + CHANGELOG.md | 122 + LICENSE | 21 + Makefile | 27 + README.en.md | 430 +++ README.md | 427 +++ SECURITY.md | 50 + auth.py | 109 + docker-compose.example.yml | 14 + guko.py | 86 + install.sh | 16 + jiaoops.py | 6 + optional/README.md | 5 + optional/update_from_nezha.py | 213 ++ release.sh | 96 + scripts/security-scan.sh | 33 + servers.example.json | 43 + telegram-bot/Dockerfile | 21 + telegram-bot/bot.py | 3942 ++++++++++++++++++++ telegram-bot/render_checkplace.py | 145 + telegram-bot/requirements.txt | 3 + telegram-bot/tools/bgp_fetch.py | 241 ++ telegram-bot/tools/download_ippure.js | 118 + 30 files changed, 6419 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.en.md create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 auth.py create mode 100644 docker-compose.example.yml create mode 100755 guko.py create mode 100755 install.sh create mode 100755 jiaoops.py create mode 100644 optional/README.md create mode 100755 optional/update_from_nezha.py create mode 100755 release.sh create mode 100755 scripts/security-scan.sh create mode 100644 servers.example.json create mode 100644 telegram-bot/Dockerfile create mode 100755 telegram-bot/bot.py create mode 100755 telegram-bot/render_checkplace.py create mode 100644 telegram-bot/requirements.txt create mode 100755 telegram-bot/tools/bgp_fetch.py create mode 100755 telegram-bot/tools/download_ippure.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0af44dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.env +servers.json +servers.yml +docker-compose.yml +keys/ +media/ +tmp/ +results/ +backups/ +__pycache__/ +*.pyc +*.bak* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56d6f94 --- /dev/null +++ b/.env.example @@ -0,0 +1,44 @@ +# 必填:自己去 BotFather 创建 bot,填自己的 token +BOT_TOKEN=replace-me + +# 必填:Telegram 用户 ID 白名单,逗号分隔。为空时程序拒绝启动。 +ALLOWED_USERS=your-telegram-user-id + +# 可选:管理员白名单。为空时默认等于 ALLOWED_USERS。 +# 管理员能添加/删除服务器、执行高危命令。 +ADMIN_USERS=your-telegram-user-id + +DATA_DIR=/data +GUKO_INV=/data/servers.json +MEDIA_DIR=/data/media +TMP_DIR=/data/tmp +KEYS_DIR=/data/keys +GUKO_VERSION=0.1.24 + +# 可选:对接 Kulin/Komari 面板后,添加/刷新服务器地区时优先使用面板的 GeoIP 结果。 +# 不配置时会自动回退到公开 GeoIP API。 +KULIN_BASE_URL= +KULIN_USERNAME= +KULIN_PASSWORD= +KULIN_API_CACHE_TTL=300 + +GUKO_DEFAULT_USER=root +GUKO_DEFAULT_PORT=22 +GUKO_DEFAULT_KEY=/data/keys/id_ed25519 + + +# 可选:关闭某些功能按钮。 +ENABLE_BGP=true +ENABLE_IPPURE=true +ENABLE_IPQ=true +ENABLE_NQ=true +ENABLE_GB5=true +ENABLE_STREAM=true +ENABLE_NEXTTRACE=true + +# 可选:本地图像工具脚本路径。缺失时 Bot 会尝试自动下载。 +BGP_FETCH=/data/tools/bgp_fetch.py +IPPURE_DOWNLOAD=/data/tools/download_ippure.js + +# 开发/迁移时才用:跳过安全启动检查。 +ALLOW_INSECURE_STARTUP=false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..54bb28a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,25 @@ +name: Bug report +description: Report a reproducible problem +title: "[Bug] " +labels: ["bug"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What happened? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Reproduction steps + description: Steps to reproduce the issue. + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / screenshots + description: Remove tokens, IPs, passwords and private keys before posting. + render: text diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..94f211a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,17 @@ +name: Feature request +description: Suggest an improvement +title: "[Feature] " +labels: ["enhancement"] +body: + - type: textarea + id: use-case + attributes: + label: Use case + description: What problem would this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What should change? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..74fb313 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + pull_request: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Bash syntax + run: bash -n release.sh install.sh release.sh scripts/security-scan.sh + - name: Secret scan + run: bash scripts/security-scan.sh + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Python compile + run: python -m py_compile auth.py guko.py jiaoops.py telegram-bot/bot.py optional/update_from_nezha.py + - name: Docker build + run: docker build -f telegram-bot/Dockerfile . diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..2dcedd9 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,45 @@ +name: Publish Docker image + +on: + push: + branches: [main] + tags: + - 'v*.*.*' + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Login to GHCR + run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + - name: Build image + run: | + IMAGE=ghcr.io/${{ github.repository_owner }}/guko + TAGS="-t $IMAGE:${{ github.sha }}" + if [ "${{ github.ref_type }}" = "branch" ] && [ "${{ github.ref_name }}" = "main" ]; then + TAGS="$TAGS -t $IMAGE:latest" + fi + if [ "${{ github.ref_type }}" = "tag" ]; then + TAGS="$TAGS -t $IMAGE:${{ github.ref_name }} -t $IMAGE:latest" + fi + docker build -f telegram-bot/Dockerfile $TAGS . + - name: Push image + run: | + IMAGE=ghcr.io/${{ github.repository_owner }}/guko + docker push "$IMAGE:${{ github.sha }}" + if [ "${{ github.ref_type }}" = "branch" ] && [ "${{ github.ref_name }}" = "main" ]; then + docker push "$IMAGE:latest" + fi + if [ "${{ github.ref_type }}" = "tag" ]; then + docker push "$IMAGE:${{ github.ref_name }}" + docker push "$IMAGE:latest" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5589006 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Draft GitHub Release + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to release, for example v0.1.1' + required: true + type: string + +permissions: + contents: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Resolve tag + id: vars + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + fi + - name: Build release notes from CHANGELOG + shell: bash + run: | + tag="${{ steps.vars.outputs.tag }}" + version="${tag#v}" + awk -v version="$version" ' + index($0, "## [" version "]") == 1 { capture=1; next } + capture && index($0, "## [") == 1 { exit } + capture { print } + ' CHANGELOG.md > /tmp/release-notes.md + if [ ! -s /tmp/release-notes.md ]; then + printf 'Release %s\n' "$tag" > /tmp/release-notes.md + fi + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${{ steps.vars.outputs.tag }}" + assets=(docker-compose.example.yml .env.example) + existing=() + for asset in "${assets[@]}"; do + [ -f "$asset" ] && existing+=("$asset") + done + if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --title "$tag" --notes-file /tmp/release-notes.md + if [ ${#existing[@]} -gt 0 ]; then + gh release upload "$tag" "${existing[@]}" --clobber + fi + else + gh release create "$tag" "${existing[@]}" --title "$tag" --notes-file /tmp/release-notes.md + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d7f81c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# secrets / runtime data +.env +servers.json +servers.yml +keys/ +media/ +tmp/ +results/ +*.bak* +__pycache__/ +*.pyc + +docker-compose.yml +history.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..60d81f3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,122 @@ +# Changelog + +## [0.1.24] - 2026-06-21 + +### Added +- 服务器详情页在 IP/SSH 信息下方显示 CPU 核心数、内存和硬盘容量。 +- 首次读取服务器配置后缓存到本地清单,后续打开详情直接使用缓存。 + +### Changed +- 内存和硬盘容量统一保留两位小数,小于 1GB 时显示为 MB。 + +## [0.1.23] - 2026-06-16 + +- 修复 IPPure 图片生成在 Playwright 浏览器缓存缺失时失败的问题,改为随镜像安装系统 Chromium 并优先使用可用浏览器。 +- 加强 IPPure 工具环境检查,避免只检测到 Playwright npm 包但缺少浏览器二进制时误判为可用。 + +## [0.1.22] - 2026-06-15 + +- 地区识别可选对接 Kulin/Komari 面板 GeoIP 结果,减少与面板显示不一致。 +- 优化地区识别公开 API 回退顺序。 +- 修复 VLESS 安装自动输入的换行处理。 + +## [0.1.21] - 2026-06-15 + +- 更新地区识别数据库来源,优先使用更新的 IP 地理位置数据。 +- Docker 发布镜像补充版本标签,并确保版本 tag 与 latest 指向同一发布镜像。 + +## [0.1.20] - 2026-06-05 + +- 将 TW 地区节点的默认旗帜显示为 🇨🇳。 +- 地区识别优先对齐 Kulin/Nezha 使用的 GeoIP 结果。 + +## [0.1.19] - 2026-06-04 + +- 节点配置里的 VLESS/SS/AnyTLS 等节点链接改为可复制的代码块格式。 +- 恢复测试结果报告链接为普通链接展示。 + +## [0.1.18] - 2026-06-04 + +- 优化测试结果里的报告链接展示,改为可直接复制的代码块格式。 + +## [0.1.17] - 2026-06-04 + +- 修复 VLESS 安装/查看逻辑:已有 VLESS 配置时识别任意 inbound,不再只检查第一个 inbound。 +- 避免在目标机已有非 VLESS 的 Xray 配置时被 GUKO VLESS 安装覆盖,改为提示冲突并退出。 +- 修复 Xray-VLESS-Manager 在缺少 `geoip.dat` 的机器上因 `geoip:private` 导致启动失败的问题。 + +## [0.1.16] - 2026-06-04 + +- NodeQuality 完成消息不再输出调试用的“原始报告接口”。 +- 保持 NodeQuality 选择页默认空选,由用户自行选择项目或点击全选。 + +## [0.1.15] - 2026-06-04 + +- 修复 VLESS 半安装状态误判。 +- 优化 VLESS 默认端口冲突处理。 + +## [0.1.14] - 2026-06-03 + +- 优化节点结果输出格式。 +- 修复 VLESS 重复安装问题。 + +## [0.1.13] - 2026-06-02 + +- 修复 NodeQuality 结果链接可能被 curl 进度数字污染,确保只保留有效 32 位 token。 +- 完整 NodeQuality / 全选任务只发送总结果链接,不再发送分项图片;单选或部分选择仍会发送对应分项图片。 +- 修复 NodeQuality 历史记录丢失关键链接的问题,优先保留总结果、Report.Check.Place 分项报告和 Geekbench 链接。 +- 修复 NetQuality 在部分机器的 TCP 大包延迟阶段卡住导致空结果的问题,为探测步骤加入超时保护。 +- 保留官方 NodeQuality 生成的结果链接,不再二次上传空包或坏包生成无效链接。 + +## [0.1.12] - 2026-06-02 + +- 新增 VLESS 与 Snell 管理入口;VLESS 安装可选择纯 VLESS 或 Vision + Reality。 + +## [0.1.11] - 2026-06-02 + +- 回退 Check.Place 报告渲染为原 Python/Pillow 终端网格方案,移除实验性的浏览器截图渲染。 + +## [0.1.10] - 2026-06-01 + +- 移除任务队列模式,点击测试后直接启动后台任务;同一服务器同类任务运行中时直接提示。 + +## [0.1.9] - 2026-05-28 + +- 新增 SS-Rust 与 AnyTLS 管理入口,支持安装/更新检测与查看配置。 + +## [0.1.8] - 2026-05-22 + +- 移除老用户 Compose project 改名迁移脚本,避免把一次性改名流程误认为日常升级步骤。 +- 保留新版 Compose 的 `name: guko`,新部署会直接显示为 `guko`;老部署正常更新镜像不受影响。 + +## [0.1.7] - 2026-05-22 + +- 增加老用户 Compose project name 迁移脚本,稳定处理从目录名 project 迁移到 `guko` 的容器名冲突。 +- README 增加老部署升级说明,明确保留宿主机挂载数据。 + +## [0.1.6] - 2026-05-22 + +- Docker Compose 示例显式设置项目名为 `guko`,避免部署目录名影响管理面板显示。 +- 更新 README 部署说明,说明 Compose project name 会固定显示为 `guko`。 + +## [0.1.5] - 2026-05-20 + +- 修正文档中的安全表述,补充 Issue 模板,并完善 Release 附件。 + +## [0.1.4] - 2026-05-20 + +- 加入基础 CI、防泄密检查、Actions Node24 兼容设置和本地项目健康检查。 + +## [0.1.3] - 2026-05-20 + +- 补齐中英双语 README、统一部署说明,并加入本地 release helper。 + +All notable changes to this project are documented here. + +## [0.1.2] - 2026-05-19 + +- 修复 Release workflow YAML,确保 tag 发布会自动用 CHANGELOG 生成 Release notes。 + +## [0.1.1] - 2026-05-19 + +- 维护版本发布流程:新增 CHANGELOG 与 Release Drafter;Docker 发布保留 latest、版本号和 sha 标签。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b3df959 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GUKO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e9e397b --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: init up down restart logs build check + +init: + @test -f .env || cp .env.example .env + @test -f servers.json || cp servers.example.json servers.json + @mkdir -p keys media tmp + @echo "Initialized. Edit .env, then run: make up" + +build: + docker compose -f docker-compose.example.yml build + +up: + docker compose -f docker-compose.example.yml up -d --build + +down: + docker compose -f docker-compose.example.yml down + +restart: + docker compose -f docker-compose.example.yml restart + +logs: + docker compose -f docker-compose.example.yml logs -f --tail=200 + +check: + python3 -m py_compile auth.py guko.py jiaoops.py telegram-bot/bot.py optional/update_from_nezha.py + @! grep -R "BOT_TOKEN=.*[0-9][0-9][0-9].*:" -n . --exclude='.env.example' --exclude-dir='.git' || (echo "Possible token leak" && exit 1) + @! grep -R "BEGIN .*PRIVATE KEY" -n . --exclude-dir='.git' || (echo "Private key leak" && exit 1) diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..a7aef53 --- /dev/null +++ b/README.en.md @@ -0,0 +1,430 @@ +# GUKO + +[![Docker Image](https://img.shields.io/badge/ghcr.io-guko-blue?logo=docker)](https://github.com/shuijiao1/GUKO/pkgs/container/guko) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +[中文](README.md) | **English** + +**A lightweight VPS / server management Telegram Bot: server status dashboard, SSH login management, common diagnostics, and protocol management entries.** + +> Open a private chat with the Bot to view server lists, status details, traffic, and resource usage. Add servers, test SSH, and run IP quality, NodeQuality, streaming unlock, NextTrace, GB5, SS-Rust, AnyTLS, VLESS, Snell, and other common checks from Telegram. +> Whitelist mode is enabled by default, making it suitable for self-hosting. + +--- + +## 🎯 Features + +- **Server dashboard**: View online count, CPU / memory / disk usage, traffic, realtime network speed, and system information. +- **Add servers from Telegram**: Supports single-server add, batch import, edit, delete, and SSH connectivity tests. +- **Flexible SSH authentication**: Supports inherited default keys, per-server keys, existing key paths, uploaded / pasted private keys, and password login. +- **Common test shortcuts**: Supports IP quality, NodeQuality, streaming unlock checks, NextTrace, GB5, and more. +- **IP / domain tools**: Supports IPPure official images and bgp.tools BGP route images. +- **Safe defaults**: Whitelist mode is required; GUKO focuses on common tests and does not provide a general remote command execution feature. +- **Docker-friendly deployment**: Includes Docker Compose, Makefile, and initialization script. + +--- + +## 🚀 Quick Start + +Prepare first: + +1. Create a Bot via [@BotFather](https://t.me/BotFather) and get `BOT_TOKEN`. +2. Use [@userinfobot](https://t.me/userinfobot) or [@RawDataBot](https://t.me/RawDataBot) to get your numeric Telegram user ID. + +Two deployment methods are available. **Docker Compose is recommended**. + +### Method 1: Docker Compose (recommended, no git clone) + +```bash +mkdir -p guko/keys guko/media guko/results guko/tmp +cd guko + +curl -Lo docker-compose.yml https://github.com/shuijiao1/GUKO/releases/latest/download/docker-compose.example.yml + +cat > .env <<'EOF' +BOT_TOKEN=replace-me +ALLOWED_USERS=123456789 +ADMIN_USERS=123456789 +DATA_DIR=/data +GUKO_INV=/data/servers.json +MEDIA_DIR=/data/media +TMP_DIR=/data/tmp +KEYS_DIR=/data/keys +GUKO_DEFAULT_USER=root +GUKO_DEFAULT_PORT=22 +GUKO_DEFAULT_KEY=/data/keys/id_ed25519 +# Optional: prefer Kulin/Komari panel GeoIP results for region detection +KULIN_BASE_URL= +KULIN_USERNAME= +KULIN_PASSWORD= +KULIN_API_CACHE_TTL=300 +ENABLE_BGP=true +ENABLE_IPPURE=true +ENABLE_IPQ=true +ENABLE_NQ=true +ENABLE_GB5=true +ENABLE_STREAM=true +ENABLE_NEXTTRACE=true +ALLOW_INSECURE_STARTUP=false +EOF + +cat > servers.json <<'EOF' +{ + "defaults": { + "ssh": { + "user": "root", + "port": 22, + "key": "/data/keys/id_ed25519" + } + }, + "servers": [] +} +EOF + +nano .env +docker compose pull +docker compose up -d +docker compose logs -f +``` + +`docker-compose.yml` explicitly sets `name: guko`, so Docker / DockUP and other management panels show the project as `guko` instead of deriving a random name from the deployment directory. + +Only these values need to be changed in the minimal config first: + +```env +BOT_TOKEN=replace-me +ALLOWED_USERS=123456789 +ADMIN_USERS=123456789 +``` + +After startup, send `/addserver` to the Bot to add your first server. + + +### Method 2: Source build (development) + +```bash +git clone https://github.com/shuijiao1/GUKO.git +cd GUKO +cp .env.example .env +cp servers.example.json servers.json +mkdir -p keys media tmp +nano .env +docker build -f telegram-bot/Dockerfile -t guko:local . +docker run -d --name guko-bot --restart unless-stopped \ + --env-file .env \ + -v ./servers.json:/data/servers.json \ + -v ./keys:/data/keys \ + -v ./media:/data/media \ + -v ./tmp:/data/tmp \ + guko:local +docker logs -f guko-bot +``` + +--- + +## 💬 Usage + +### Open dashboard + +Send this to the Bot in a private chat: + +```text +/start +``` + +The Bot will show the GUKO dashboard. Tap a server to view details. + +### Add servers + +Tap **➕ Add Server**, or send: + +```text +/addserver +``` + +#### Add one server + +Choose **Add single server**, then send: + +```text +name IP [port] [user] +``` + +Examples: + +```text +hk-01 203.0.113.10 22 root +jp-01 203.0.113.20:2222 debian +``` + +Then the Bot will ask for the login method: + +- **Use default key / config**: Use `GUKO_DEFAULT_KEY` or `defaults.ssh.key` from `servers.json`. +- **Use existing key path**: Send a path such as `/data/keys/id_ed25519`. +- **Upload / paste a new private key**: Send SSH private key text, or upload a private key file. The Bot saves it to `/data/keys/`, sets permission to `600`, and tries an SSH login test. +- **Use password**: Send SSH password. The Bot saves the config and tries a login test. +- **Save only, skip test**: Only write the server record. You can add authentication later. + +After adding, test with buttons or commands: + +```text +/testssh hk-01 +/testall +``` + +The server detail page also supports **Edit** and **Delete**. Delete requires confirmation and only removes local Bot configuration; it does not touch the remote machine. + +#### Batch import + +Choose **Batch import**. The Bot will ask: + +1. Whether all servers use the same SSH port, or each line includes its own port. +2. Whether all servers use the same key, same password, per-line auth, or import without testing. + +Common batch format: + +```text +hk-01 203.0.113.10 root +jp-01 203.0.113.20 debian +sg-01 203.0.113.30 root +``` + +If choosing per-line ports: + +```text +hk-01 203.0.113.10 22 root +jp-01 203.0.113.20 2222 debian +sg-01 203.0.113.30:53580 root +``` + +If choosing per-line auth: + +```text +hk-01 203.0.113.10 22 root key:/data/keys/hk_ed25519 +jp-01 203.0.113.20 2222 debian password:your-password +``` + +> Passwords / private keys sent through Telegram pass through Telegram cloud. Use a private Bot and restrict `ALLOWED_USERS`. + +### Commands + +- `/start` — Open GUKO dashboard +- `/list` — Show server list +- `/status` — Show overview status +- `/addserver` — Add / batch import servers +- `/testssh ` — Test SSH for one server +- `/testall` — Batch test SSH +- `/exportconfig` — Export sanitized config +- `/info ` — Show single-server details +- `/health` — Read-only health check +- `/jobs` — Show background jobs +- `/ip ` — IPPure / BGP tools +- `/nexttrace ` — Route tracing + +--- + +## ⚙️ Configuration + +`.env` example: + +```env +BOT_TOKEN=replace-me +ALLOWED_USERS=123456789 +ADMIN_USERS=123456789 +DATA_DIR=/data +GUKO_INV=/data/servers.json +MEDIA_DIR=/data/media +TMP_DIR=/data/tmp +KEYS_DIR=/data/keys +GUKO_DEFAULT_USER=root +GUKO_DEFAULT_PORT=22 +GUKO_DEFAULT_KEY=/data/keys/id_ed25519 +ENABLE_BGP=true +ENABLE_IPPURE=true +ENABLE_IPQ=true +ENABLE_NQ=true +ENABLE_GB5=true +ENABLE_STREAM=true +ENABLE_NEXTTRACE=true +ALLOW_INSECURE_STARTUP=false +``` + +| Variable | Required | Default | Description | +|---|---:|---|---| +| `BOT_TOKEN` | Yes | - | Telegram Bot Token | +| `ALLOWED_USERS` | Yes | - | Allowed Telegram numeric user IDs, comma-separated | +| `ADMIN_USERS` | No | `ALLOWED_USERS` | Admin IDs; can add / delete servers and use high-risk features | +| `DATA_DIR` | No | `/data` | Container data directory | +| `GUKO_INV` | No | `/data/servers.json` | Server inventory path | +| `MEDIA_DIR` | No | `/data/media` | Image and report output directory | +| `TMP_DIR` | No | `/data/tmp` | Temporary directory | +| `KEYS_DIR` | No | `/data/keys` | SSH private key storage directory | +| `GUKO_DEFAULT_USER` | No | `root` | Default SSH user | +| `GUKO_DEFAULT_PORT` | No | `22` | Default SSH port | +| `GUKO_DEFAULT_KEY` | No | `/data/keys/id_ed25519` | Default SSH private key path | +| `ENABLE_BGP` | No | `true` | Enable BGP image feature | +| `ENABLE_IPPURE` | No | `true` | Enable IPPure image feature | +| `ENABLE_IPQ` | No | `true` | Enable IP quality feature | +| `ENABLE_NQ` | No | `true` | Enable NodeQuality feature | +| `ENABLE_GB5` | No | `true` | Enable GB5 feature | +| `ENABLE_STREAM` | No | `true` | Enable streaming unlock checks | +| `ENABLE_NEXTTRACE` | No | `true` | Enable NextTrace | +| `BGP_FETCH` | No | `/data/tools/bgp_fetch.py` | BGP image helper script path | +| `IPPURE_DOWNLOAD` | No | `/data/tools/download_ippure.js` | IPPure download script path | +| `ALLOW_INSECURE_STARTUP` | No | `false` | Skip security startup checks for development / migration | + +> `BOT_TOKEN` and `ALLOWED_USERS` are required. Do not commit real `.env` files. + +--- + +## 🛠 Operations + +Persistent data lives in the installation directory: + +```text +GUKO/ +├── docker-compose.example.yml +├── .env +├── servers.json # private server inventory +├── keys/ # SSH private keys +├── media/ # report images / output files +└── tmp/ # temporary files +``` + +Common commands: + +```bash +cd +docker compose ps +docker compose logs -f +docker compose restart +docker compose down +``` + +Upgrade: + +```bash +cd +git pull +docker compose pull +docker compose up -d +``` + +Or use Makefile: + +```bash +make up +make logs +make restart +make down +``` + +--- + +## 🧾 Batch add via config file + +It is recommended to put shared defaults under `defaults.ssh`, and only override differences per server: + +```json +{ + "defaults": { + "ssh": { + "user": "root", + "port": 22, + "key": "~/.ssh/id_ed25519" + } + }, + "servers": [ + { + "name": "hk-01", + "host": "203.0.113.10" + }, + { + "name": "jp-01", + "host": "203.0.113.20", + "ssh": { + "user": "debian", + "port": 2222, + "key": "~/.ssh/jp_ed25519" + } + }, + { + "name": "sg-password", + "host": "203.0.113.30", + "ssh": { + "auth": "password", + "password": "change-me" + } + } + ] +} +``` + +Legacy format is still supported: + +```json +{ + "name": "legacy", + "host": "203.0.113.40", + "user": "root", + "port": 53580, + "key": "/data/keys/server_key" +} +``` + +Test after batch import: + +```bash +./guko.py list +./guko.py run hk-01 'hostname' +``` + +You can also export sanitized config from the Bot: + +```text +/exportconfig +``` + +--- + +## 🧩 Optional tools + +GUKO can enable IP quality, NodeQuality, streaming unlock checks, NextTrace, GB5, BGP images, IPPure images, and other tools as needed. Related buttons can be disabled with environment variables. + +--- + +## 🧩 Source run (development) + +```bash +git clone https://github.com/shuijiao1/GUKO.git +cd GUKO +python3 -m venv .venv +. .venv/bin/activate +pip install -r telegram-bot/requirements.txt +cp .env.example .env +cp servers.example.json servers.json +nano .env +python3 telegram-bot/bot.py +``` + +Syntax check: + +```bash +make check +``` + +--- + +## 🔐 Privacy + +- The repository does not contain any Bot Token, real user ID, server password, or private key. +- `.env`, `servers.json`, `keys/`, `media/`, and `tmp/` are ignored by Git. Do not commit real configuration. +- Whitelist mode is enabled by default. The Bot refuses to start when allowed users are not configured. +- IPPure, bgp.tools, NodeQuality, streaming checks, and similar features will access corresponding third-party services. +- Deleting a server only removes local Bot configuration. It does not delete or reinstall the remote machine. + +## License + +MIT + +--- diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca1b57f --- /dev/null +++ b/README.md @@ -0,0 +1,427 @@ +# GUKO + +[![Docker Image](https://img.shields.io/badge/ghcr.io-guko-blue?logo=docker)](https://github.com/shuijiao1/GUKO/pkgs/container/guko) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + +**中文** | [English](README.en.md) + +**轻量 VPS / 服务器管理 Telegram Bot:服务器状态面板、SSH 登录管理、常用测试脚本入口** + +> 私聊打开 Bot 就能查看服务器列表、状态详情、流量与资源占用;支持在 Telegram 内添加服务器、测试 SSH、运行 IP 质量 / NodeQuality / 流媒体 / NextTrace / GB5 等常用检测。 +> 默认白名单模式,适合自托管。 + +--- + +## 🎯 核心特性 + +- **服务器状态面板**:展示在线数量、CPU / 内存 / 硬盘、流量、实时网速、系统信息等。 +- **Telegram 内添加服务器**:支持单台添加、批量导入、编辑、删除和 SSH 连通性测试。 +- **灵活 SSH 鉴权**:支持默认密钥继承、每台独立密钥、已有密钥路径、上传 / 粘贴私钥、密码登录。 +- **常用测试与协议入口**:支持 IP 质量、NodeQuality、流媒体解锁、NextTrace、GB5、SS-Rust、AnyTLS、VLESS、Snell 等任务。 +- **IP / 域名工具**:支持 IPPure 官方图片与 bgp.tools BGP 路由图。 +- **适合 Docker 部署**:提供 Docker Compose、Makefile 和初始化脚本。 + +--- + +## 🚀 快速开始 + +先准备: + +1. 到 [@BotFather](https://t.me/BotFather) 创建 Bot,拿到 `BOT_TOKEN`。 +2. 用 [@userinfobot](https://t.me/userinfobot) 或 [@RawDataBot](https://t.me/RawDataBot) 获取你的 Telegram 数字用户 ID。 + +提供 2 种部署方式,**推荐 Docker Compose**。 + +### 方式一:Docker Compose(推荐,无需 git clone) + +```bash +mkdir -p guko/keys guko/media guko/results guko/tmp +cd guko + +curl -Lo docker-compose.yml https://github.com/shuijiao1/GUKO/releases/latest/download/docker-compose.example.yml + +cat > .env <<'EOF' +BOT_TOKEN=replace-me +ALLOWED_USERS=123456789 +ADMIN_USERS=123456789 +DATA_DIR=/data +GUKO_INV=/data/servers.json +MEDIA_DIR=/data/media +TMP_DIR=/data/tmp +KEYS_DIR=/data/keys +GUKO_DEFAULT_USER=root +GUKO_DEFAULT_PORT=22 +GUKO_DEFAULT_KEY=/data/keys/id_ed25519 +# 可选:对接 Kulin/Komari 后,地区识别优先使用面板 GeoIP 结果 +KULIN_BASE_URL= +KULIN_USERNAME= +KULIN_PASSWORD= +KULIN_API_CACHE_TTL=300 +ENABLE_BGP=true +ENABLE_IPPURE=true +ENABLE_IPQ=true +ENABLE_NQ=true +ENABLE_GB5=true +ENABLE_STREAM=true +ENABLE_NEXTTRACE=true +ALLOW_INSECURE_STARTUP=false +EOF + +cat > servers.json <<'EOF' +{ + "defaults": { + "ssh": { + "user": "root", + "port": 22, + "key": "/data/keys/id_ed25519" + } + }, + "servers": [] +} +EOF + +nano .env +docker compose pull +docker compose up -d +docker compose logs -f +``` + +`docker-compose.yml` 已显式设置 `name: guko`,因此在 Docker / DockUP 等管理面板里会显示为 `guko`,不会因为部署目录不同变成随机目录名。 + +最小配置里只需要先改: + +```env +BOT_TOKEN=replace-me +ALLOWED_USERS=123456789 +ADMIN_USERS=123456789 +``` + +启动后在 Bot 里发送 `/addserver` 添加第一台服务器。 + + +### 方式二:源码构建(开发用) + +```bash +git clone https://github.com/shuijiao1/GUKO.git +cd GUKO +cp .env.example .env +cp servers.example.json servers.json +mkdir -p keys media tmp +nano .env +docker build -f telegram-bot/Dockerfile -t guko:local . +docker run -d --name guko-bot --restart unless-stopped \ + --env-file .env \ + -v ./servers.json:/data/servers.json \ + -v ./keys:/data/keys \ + -v ./media:/data/media \ + -v ./tmp:/data/tmp \ + guko:local +docker logs -f guko-bot +``` + +--- + +## 💬 使用方式 + +### 打开面板 + +私聊 Bot 发送: + +```text +/start +``` + +Bot 会显示 GUKO 总览面板,可以点服务器查看详情。 + +### 添加服务器 + +点击 **➕ 添加服务器**,或者发送: + +```text +/addserver +``` + +#### 单台添加 + +选择 **添加单台**,按提示发送: + +```text +名称 IP [端口] [用户] +``` + +示例: + +```text +hk-01 203.0.113.10 22 root +jp-01 203.0.113.20:2222 debian +``` + +然后 Bot 会询问登录方式,支持密钥路径、上传 / 粘贴私钥、密码或先保存后测试。 + +添加后可用按钮或命令测试: + +```text +/testssh hk-01 +/testall +``` + +服务器详情页也支持 **编辑** 和 **删除**;删除需要二次确认,只会删除本地配置,不会操作远端机器。 + +#### 批量导入 + +选择 **批量导入** 后,Bot 会先问: + +1. 是否全部使用同一个 SSH 端口,还是每台自己写端口。 +2. 是否全部使用同一把密钥、同一个密码、每台自己写认证,还是先只导入不测试。 + +常用批量格式: + +```text +hk-01 203.0.113.10 root +jp-01 203.0.113.20 debian +sg-01 203.0.113.30 root +``` + +如果选择“每台自己写端口”: + +```text +hk-01 203.0.113.10 22 root +jp-01 203.0.113.20 2222 debian +sg-01 203.0.113.30:53580 root +``` + +如果选择“每台自己写认证”: + +```text +hk-01 203.0.113.10 22 root key:/data/keys/hk_ed25519 +jp-01 203.0.113.20 2222 debian password:your-password +``` + +> Telegram 里发送密码 / 私钥会经过 Telegram 云端。建议使用私有 Bot,并限制 `ALLOWED_USERS`。 + +### 命令 + +- `/start` — 打开 GUKO 面板 +- `/list` — 查看服务器列表 +- `/status` — 查看总览状态 +- `/addserver` — 添加 / 批量导入服务器 +- `/testssh <名字/IP/ID/别名>` — 测试单台服务器 SSH +- `/testall` — 批量测试 SSH +- `/exportconfig` — 导出脱敏配置 +- `/info <名字/IP/ID/别名>` — 查看单台详情 +- `/health` — 只读巡检 +- `/jobs` — 查看后台任务 +- `/ip ` — IPPure / BGP 工具 +- `/nexttrace <服务器> <目标>` — 路由追踪 + +--- + +## ⚙️ 配置说明 + +`.env` 示例: + +```env +BOT_TOKEN=replace-me +ALLOWED_USERS=123456789 +ADMIN_USERS=123456789 +DATA_DIR=/data +GUKO_INV=/data/servers.json +MEDIA_DIR=/data/media +TMP_DIR=/data/tmp +KEYS_DIR=/data/keys +GUKO_DEFAULT_USER=root +GUKO_DEFAULT_PORT=22 +GUKO_DEFAULT_KEY=/data/keys/id_ed25519 +ENABLE_BGP=true +ENABLE_IPPURE=true +ENABLE_IPQ=true +ENABLE_NQ=true +ENABLE_GB5=true +ENABLE_STREAM=true +ENABLE_NEXTTRACE=true +ALLOW_INSECURE_STARTUP=false +``` + +| 变量 | 是否必填 | 默认值 | 说明 | +|---|---:|---|---| +| `BOT_TOKEN` | 是 | - | Telegram Bot Token | +| `ALLOWED_USERS` | 是 | - | 允许使用 Bot 的 Telegram 数字 ID,多个用英文逗号分隔 | +| `ADMIN_USERS` | 否 | `ALLOWED_USERS` | 管理员 ID,能添加 / 删除服务器、执行高危功能 | +| `DATA_DIR` | 否 | `/data` | 容器内数据目录 | +| `GUKO_INV` | 否 | `/data/servers.json` | 服务器清单路径 | +| `MEDIA_DIR` | 否 | `/data/media` | 图片和报告输出目录 | +| `TMP_DIR` | 否 | `/data/tmp` | 临时文件目录 | +| `KEYS_DIR` | 否 | `/data/keys` | SSH 私钥保存目录 | +| `GUKO_DEFAULT_USER` | 否 | `root` | 默认 SSH 用户 | +| `GUKO_DEFAULT_PORT` | 否 | `22` | 默认 SSH 端口 | +| `GUKO_DEFAULT_KEY` | 否 | `/data/keys/id_ed25519` | 默认 SSH 私钥路径 | +| `ENABLE_BGP` | 否 | `true` | 是否启用 BGP 图功能 | +| `ENABLE_IPPURE` | 否 | `true` | 是否启用 IPPure 图功能 | +| `ENABLE_IPQ` | 否 | `true` | 是否启用 IP 质量功能 | +| `ENABLE_NQ` | 否 | `true` | 是否启用 NodeQuality 功能 | +| `ENABLE_GB5` | 否 | `true` | 是否启用 GB5 功能 | +| `ENABLE_SS` | 否 | `true` | 是否启用 SS-Rust 管理入口 | +| `ENABLE_ANYTLS` | 否 | `true` | 是否启用 AnyTLS 管理入口 | +| `ENABLE_VLESS` | 否 | `true` | 是否启用 VLESS 管理入口 | +| `ENABLE_SNELL` | 否 | `true` | 是否启用 Snell 管理入口 | +| `ENABLE_STREAM` | 否 | `true` | 是否启用流媒体检测 | +| `ENABLE_NEXTTRACE` | 否 | `true` | 是否启用 NextTrace | +| `BGP_FETCH` | 否 | `/data/tools/bgp_fetch.py` | BGP 图片工具脚本路径 | +| `IPPURE_DOWNLOAD` | 否 | `/data/tools/download_ippure.js` | IPPure 下载脚本路径 | +| `ALLOW_INSECURE_STARTUP` | 否 | `false` | 开发 / 迁移时跳过安全启动检查 | + +> `BOT_TOKEN` 和 `ALLOWED_USERS` 必须填写;不要把真实 `.env` 提交到仓库。 + +--- + +## 🛠 运维 + +所有持久化数据在安装目录下: + +```text +GUKO/ +├── docker-compose.example.yml +├── .env +├── servers.json # 私有服务器清单 +├── keys/ # SSH 私钥 +├── media/ # 报告图片 / 输出文件 +└── tmp/ # 临时文件 +``` + +常用命令: + +```bash +cd <安装目录> +docker compose ps +docker compose logs -f +docker compose restart +docker compose down +``` + +升级: + +```bash +cd <安装目录> +git pull +docker compose pull +docker compose up -d +``` + +也可以使用 Makefile: + +```bash +make up +make logs +make restart +make down +``` + +--- + +## 🧾 直接写配置文件批量添加 + +推荐在 `defaults.ssh` 里写公共默认值,每台服务器只覆盖不同的部分: + +```json +{ + "defaults": { + "ssh": { + "user": "root", + "port": 22, + "key": "~/.ssh/id_ed25519" + } + }, + "servers": [ + { + "name": "hk-01", + "host": "203.0.113.10" + }, + { + "name": "jp-01", + "host": "203.0.113.20", + "ssh": { + "user": "debian", + "port": 2222, + "key": "~/.ssh/jp_ed25519" + } + }, + { + "name": "sg-password", + "host": "203.0.113.30", + "ssh": { + "auth": "password", + "password": "change-me" + } + } + ] +} +``` + +兼容旧格式,下面这种仍然可用: + +```json +{ + "name": "legacy", + "host": "203.0.113.40", + "user": "root", + "port": 53580, + "key": "/data/keys/server_key" +} +``` + +批量添加后可以测试: + +```bash +./guko.py list +./guko.py run hk-01 'hostname' +``` + +Bot 内还可以导出脱敏配置: + +```text +/exportconfig +``` + +--- + +## 🧩 可选工具 + +GUKO 支持按需启用 IP 质量、NodeQuality、流媒体、NextTrace、GB5、BGP 图、IPPure 图、SS-Rust、AnyTLS、VLESS、Snell 等功能。相关按钮可以通过环境变量关闭。 + +--- + +--- + +## 🧩 源码运行(开发用) + +```bash +git clone https://github.com/shuijiao1/GUKO.git +cd GUKO +python3 -m venv .venv +. .venv/bin/activate +pip install -r telegram-bot/requirements.txt +cp .env.example .env +cp servers.example.json servers.json +nano .env +python3 telegram-bot/bot.py +``` + +语法检查: + +```bash +make check +``` + +--- + +## 🔐 隐私说明 + +- 仓库不包含任何 Bot Token、真实用户 ID、服务器密码或私钥。 +- `.env`、`servers.json`、`keys/`、`media/`、`tmp/` 已加入 `.gitignore`,不要提交真实配置。 +- 默认白名单模式,未配置允许用户时会拒绝启动。 +- 使用 IPPure、bgp.tools、NodeQuality、流媒体检测等功能时,会访问对应第三方服务。 +- 删除服务器只会删除 Bot 本地配置,不会删除或重装远端机器。 + +## License + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b3a4378 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# 安全说明 + +GUKO 是自部署服务器管理 Bot。它会保存 SSH 凭据,并能触发预置的只读/测试类任务;当前定位不是通用远程命令执行工具。请按下面规则部署。 + +## 必须自建 Bot + +- 不要使用别人提供的公共 Bot。 +- 请在 Telegram BotFather 创建自己的 Bot,并把 token 写入 `.env` 的 `BOT_TOKEN`。 +- 不要把 Bot Token 提交到 GitHub、截图或发到群聊。 + +## 必须白名单 + +- `ALLOWED_USERS` 不能为空;为空时程序会拒绝启动。 +- 非白名单用户只能看到“无权限”和自己的 Telegram ID。 +- 建议只允许自己的 Telegram 用户 ID。 +- 不建议把 Bot 加入群聊;如果一定要加群,也不要把群成员都加入白名单。 + +## 管理员权限 + +- `ADMIN_USERS` 为空时默认等于 `ALLOWED_USERS`。 +- 管理员可以添加/修改服务器、管理凭据,并触发预置测试任务。 +- 普通白名单用户只能查看和运行允许的只读功能。 + +## SSH 密钥和密码 + +- 推荐使用 SSH 密钥,不推荐密码。 +- 通过 Telegram 发送私钥/密码会经过 Telegram 云端;如果介意,请手动把密钥放到服务器 `./keys` 目录,然后在配置里写路径。 +- Bot 保存上传的私钥时会放到 `/data/keys/` 并设置 `0600` 权限。 +- Bot 不会在回复里回显私钥或密码。 + +## 远程命令 + +- `/run` 默认关闭。 +- 这等价于远程 SSH 执行命令,风险很高,请谨慎开启。 + +## 开源/备份 + +不要提交或公开: + +- `.env` +- `servers.json` +- `keys/` +- `docker-compose.yml` 中的真实环境变量 +- 任何 Bot Token、SSH 密码、私钥、真实服务器 IP 清单 + +仓库只应提交: + +- `.env.example` +- `servers.example.json` +- `docker-compose.example.yml` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..d0847a0 --- /dev/null +++ b/auth.py @@ -0,0 +1,109 @@ +import os +import shlex +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +DEFAULT_KEY = os.environ.get('GUKO_DEFAULT_KEY') or os.environ.get('VPSPILOT_DEFAULT_KEY') or os.environ.get('JIAOOPS_DEFAULT_KEY', '/data/keys/id_ed25519') +DEFAULT_PORT = int(os.environ.get('GUKO_DEFAULT_PORT') or os.environ.get('VPSPILOT_DEFAULT_PORT') or os.environ.get('JIAOOPS_DEFAULT_PORT', '22')) +DEFAULT_USER = os.environ.get('GUKO_DEFAULT_USER') or os.environ.get('VPSPILOT_DEFAULT_USER') or os.environ.get('JIAOOPS_DEFAULT_USER', 'root') + + +def inventory_defaults(inv: dict | None = None) -> dict: + inv = inv or {} + defaults = inv.get('defaults') or {} + ssh = defaults.get('ssh') or defaults + return { + 'user': ssh.get('user') or defaults.get('user') or DEFAULT_USER, + 'port': ssh.get('port') or defaults.get('port') or DEFAULT_PORT, + 'key': ssh.get('key') or defaults.get('key') or DEFAULT_KEY, + 'password': ssh.get('password') or defaults.get('password'), + 'auth': ssh.get('auth') or defaults.get('auth'), + } + + +def resolve_ssh(server: dict, inv: dict | None = None) -> dict: + """Resolve SSH settings with compatibility for old flat server entries. + + Supported server formats: + {"host":"1.2.3.4", "port":22, "user":"root", "key":"/path/key"} + {"host":"1.2.3.4", "ssh":{"port":22, "user":"root", "key":"/path/key"}} + {"host":"1.2.3.4", "auth":"password", "password":"secret"} + {"host":"1.2.3.4", "ssh":{"auth":"password", "password":"secret"}} + """ + defaults = inventory_defaults(inv) + ssh = server.get('ssh') or {} + explicit_key = ssh.get('key') or server.get('key') + cfg = { + 'host': ssh.get('host') or server.get('host'), + 'user': ssh.get('user') or server.get('user') or defaults['user'], + 'port': ssh.get('port') or server.get('port') or defaults['port'], + 'key': explicit_key or defaults.get('key'), + 'password': ssh.get('password') or server.get('password') or defaults.get('password'), + 'auth': ssh.get('auth') or server.get('auth') or defaults.get('auth'), + } + cfg['port'] = int(cfg['port']) + if not cfg['auth']: + cfg['auth'] = 'password' if cfg.get('password') else 'key' + if cfg['auth'] == 'password' and not explicit_key: + cfg['key'] = None + if cfg.get('key'): + cfg['key'] = os.path.expanduser(str(cfg['key'])) + return cfg + + +def _base_ssh_options(cfg: dict, *, tty=False, batch=True) -> list[str]: + args = [ + '-o', f"BatchMode={'yes' if batch else 'no'}", + '-o', 'ConnectTimeout=10', + '-o', 'ServerAliveInterval=30', + '-o', 'ServerAliveCountMax=6', + '-o', 'StrictHostKeyChecking=accept-new', + '-p', str(cfg['port']), + ] + if cfg.get('key'): + args += ['-i', str(cfg['key'])] + if tty: + args.append('-tt') + return args + + +def ssh_args(server: dict, remote: str, *, tty=False, inv: dict | None = None) -> list[str]: + cfg = resolve_ssh(server, inv) + if not cfg.get('host'): + raise ValueError('missing ssh host') + batch = not (cfg.get('auth') == 'password' and cfg.get('password')) + args = ['ssh'] + _base_ssh_options(cfg, tty=tty, batch=batch) + args += [f"{cfg['user']}@{cfg['host']}", remote] + if cfg.get('auth') == 'password' and cfg.get('password'): + return ['sshpass', '-e'] + args + return args + + +def scp_from_args(server: dict, remote_path: str, local_path: str, *, inv: dict | None = None) -> list[str]: + cfg = resolve_ssh(server, inv) + if not cfg.get('host'): + raise ValueError('missing ssh host') + batch = not (cfg.get('auth') == 'password' and cfg.get('password')) + args = [ + 'scp', + '-o', f"BatchMode={'yes' if batch else 'no'}", + '-o', 'ConnectTimeout=10', + '-o', 'StrictHostKeyChecking=accept-new', + '-P', str(cfg['port']), + ] + if cfg.get('key'): + args += ['-i', str(cfg['key'])] + args += [f"{cfg['user']}@{cfg['host']}:{remote_path}", local_path] + if cfg.get('auth') == 'password' and cfg.get('password'): + return ['sshpass', '-e'] + args + return args + + +def ssh_display(server: dict, inv: dict | None = None) -> str: + cfg = resolve_ssh(server, inv) + auth = 'password' if cfg.get('auth') == 'password' else 'key' + return f"{cfg.get('user')}@{cfg.get('host')}:{cfg.get('port')} ({auth})" + + +def shell_join(args: list[str]) -> str: + return ' '.join(shlex.quote(str(x)) for x in args) diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..c37d343 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,14 @@ +name: guko +services: + guko-bot: + image: ghcr.io/shuijiao1/guko:latest + container_name: guko-bot + restart: unless-stopped + env_file: .env + volumes: + - ./servers.json:/data/servers.json + - ./keys:/data/keys + - ./media:/data/media + - ./results:/data/results + - ./history.json:/data/history.json + - ./tmp:/data/tmp diff --git a/guko.py b/guko.py new file mode 100755 index 0000000..492d3a8 --- /dev/null +++ b/guko.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +import argparse, os, subprocess, sys, shlex, json +from pathlib import Path + +from auth import resolve_ssh, ssh_args, ssh_display + +ROOT = Path(__file__).resolve().parent +INV = Path(os.environ.get('GUKO_INV') or os.environ.get('VPSPILOT_INV') or ROOT / 'servers.json') + + +def load_inventory(): + if not INV.exists(): + return {'servers': []} + return json.loads(INV.read_text() or '{}') + + +def ssh_env_for(s, inv): + cfg = resolve_ssh(s, inv) + if cfg.get('auth') == 'password' and cfg.get('password'): + env = os.environ.copy() + env['SSHPASS'] = str(cfg['password']) + return env + return None + + +def health_cmd(): + return r'''set -e +printf 'host='; hostname +printf 'uptime='; uptime -p || true +printf 'load='; awk '{print $1,$2,$3}' /proc/loadavg +printf 'mem='; free -h | awk '/Mem:/ {print $3 "/" $2}' +printf 'disk='; df -h / | awk 'NR==2 {print $3 "/" $2 " (" $5 ")"}' +printf 'kernel='; uname -r +printf 'ssh='; ss -ltnp 2>/dev/null | grep -E ':(22|53580) ' || true +systemctl is-active nezha-agent >/dev/null 2>&1 && echo 'nezha=active' || echo 'nezha=inactive-or-missing' +''' + + +def main(): + p = argparse.ArgumentParser(prog='guko', description='GUKO server manager') + sub = p.add_subparsers(dest='cmd', required=True) + sub.add_parser('list') + sub.add_parser('health') + runp = sub.add_parser('run'); runp.add_argument('name'); runp.add_argument('command', nargs=argparse.REMAINDER) + args = p.parse_args() + inv = load_inventory() + servers = inv.get('servers') or [] + if args.cmd == 'list': + if not servers: + print('no servers in servers.json') + for s in servers: + extra = f" nezha_id={s.get('nezha_id')}" if s.get('nezha_id') is not None else '' + try: + endpoint = ssh_display(s, inv) + except Exception: + endpoint = '' + print(f"{s.get('name','?')} {endpoint} {s.get('role','')}{extra}") + elif args.cmd == 'health': + if not servers: + print('no servers in servers.json') + return + for s in servers: + if not s.get('host') and not (s.get('ssh') or {}).get('host'): + print(f"\n== {s.get('name','?')} () ==") + print('skipped: no ssh host in inventory') + continue + print(f"\n== {s.get('name',s.get('host'))} ({ssh_display(s, inv)}) ==") + r = subprocess.run(ssh_args(s, health_cmd(), inv=inv), text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30, env=ssh_env_for(s, inv)) + print(r.stdout.rstrip()) + elif args.cmd == 'run': + target = next((s for s in servers if s.get('name') == args.name or s.get('host') == args.name or str(s.get('nezha_id')) == args.name), None) + if not target: + sys.exit(f'not found: {args.name}') + if not target.get('host') and not (target.get('ssh') or {}).get('host'): + sys.exit(f"no ssh host for: {args.name}") + if not args.command: + sys.exit('missing command') + # Support both styles: + # guko.py run host 'systemctl status nginx --no-pager' + # guko.py run host systemctl status nginx --no-pager + remote = args.command[0] if len(args.command) == 1 else ' '.join(shlex.quote(x) for x in args.command) + subprocess.run(ssh_args(target, remote, inv=inv), check=False, env=ssh_env_for(target, inv)) + + +if __name__ == '__main__': + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3b438a0 --- /dev/null +++ b/install.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +[ -f .env ] || cp .env.example .env +[ -f servers.json ] || cp servers.example.json servers.json +mkdir -p keys media tmp +chmod 700 keys || true +cat <<'MSG' +GUKO initialized. + +Next steps: +1. Create your own Telegram bot with BotFather. +2. Edit .env and set BOT_TOKEN, ALLOWED_USERS, ADMIN_USERS. +3. Start: docker compose up -d +4. In Telegram, send /addserver to add your first server. +MSG diff --git a/jiaoops.py b/jiaoops.py new file mode 100755 index 0000000..56b44c4 --- /dev/null +++ b/jiaoops.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""Compatibility wrapper for the GUKO CLI.""" +from pathlib import Path +import runpy + +runpy.run_path(str(Path(__file__).with_name('guko.py')), run_name='__main__') diff --git a/optional/README.md b/optional/README.md new file mode 100644 index 0000000..4ac298f --- /dev/null +++ b/optional/README.md @@ -0,0 +1,5 @@ +# Optional Integrations + +这里放可选集成,不属于 GUKO 默认功能。 + +- `update_from_nezha.py`:实验性 Nezha 面板同步脚本。开源默认不启用、不复制进 Docker 镜像。 diff --git a/optional/update_from_nezha.py b/optional/update_from_nezha.py new file mode 100755 index 0000000..bee97d8 --- /dev/null +++ b/optional/update_from_nezha.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +import base64, json, os, socket, ssl, time, urllib.request, shutil, http.cookiejar +from pathlib import Path +from datetime import datetime + +ROOT = Path(__file__).resolve().parent +OUT = Path(os.environ.get('GUKO_INV') or os.environ.get('VPSPILOT_INV') or ROOT / 'servers.json') +DEFAULT_KEY = os.environ.get('GUKO_DEFAULT_KEY') or os.environ.get('VPSPILOT_DEFAULT_KEY') or '/data/keys/id_ed25519' +DEFAULT_PORT = int(os.environ.get('GUKO_DEFAULT_PORT') or os.environ.get('VPSPILOT_DEFAULT_PORT') or '22') +DEFAULT_USER = os.environ.get('GUKO_DEFAULT_USER') or os.environ.get('VPSPILOT_DEFAULT_USER') or 'root' +PANEL = os.environ.get('NEZHA_URL', os.environ.get('NEZHA_PANEL', '')).strip().removeprefix('https://').removeprefix('http://').rstrip('/') +WS_PATH = os.environ.get('NEZHA_WS_PATH', '/api/v1/ws/server') +SERVICE_URL = f'https://{PANEL}/api/v1/service' if PANEL else '' +SERVER_URL = f'https://{PANEL}/api/v1/server' if PANEL else '' +LOGIN_URL = f'https://{PANEL}/api/v1/login' if PANEL else '' +NEZHA_USER = os.environ.get('NEZHA_USER', '') +NEZHA_PASSWORD = os.environ.get('NEZHA_PASSWORD', '') +ALIASES = json.loads(os.environ.get('GUKO_ALIASES_JSON') or os.environ.get('VPSPILOT_ALIASES_JSON') or '{}' or '{}') + +def now_iso(): + return datetime.now().astimezone().isoformat(timespec='seconds') + +def fetch_json(url, opener=None, data=None): + req = urllib.request.Request(url) + if data is not None: + req.add_header('Content-Type', 'application/json') + req.data = json.dumps(data).encode() + if opener is None: + rctx = urllib.request.urlopen(req, timeout=10) + else: + rctx = opener.open(req, timeout=10) + with rctx as r: + return json.load(r) + +def fetch_admin_servers(): + jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + login = fetch_json(LOGIN_URL, opener, {'username': NEZHA_USER, 'password': NEZHA_PASSWORD}) + if not login.get('success'): + raise RuntimeError(login.get('error') or 'Nezha login failed') + data = fetch_json(SERVER_URL, opener) + if not data.get('success'): + raise RuntimeError(data.get('error') or 'Nezha server API failed') + return data.get('data') or [] + +def ws_read_one(host=PANEL, path=WS_PATH): + key = base64.b64encode(os.urandom(16)).decode() + ctx = ssl.create_default_context() + sock = ctx.wrap_socket(socket.create_connection((host, 443), timeout=10), server_hostname=host) + try: + req = ( + f'GET {path} HTTP/1.1\r\n' + f'Host: {host}\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + f'Sec-WebSocket-Key: {key}\r\n' + 'Sec-WebSocket-Version: 13\r\n' + f'Origin: https://{host}\r\n\r\n' + ) + sock.sendall(req.encode()) + resp = b'' + while b'\r\n\r\n' not in resp: + chunk = sock.recv(4096) + if not chunk: + break + resp += chunk + head, rest = resp.split(b'\r\n\r\n', 1) + if b' 101 ' not in head: + raise RuntimeError(head.decode(errors='replace')) + buf = rest + while len(buf) < 2: + buf += sock.recv(4096) + b2 = buf[1] + length = b2 & 0x7f + pos = 2 + if length == 126: + while len(buf) < pos + 2: + buf += sock.recv(4096) + length = int.from_bytes(buf[pos:pos+2], 'big'); pos += 2 + elif length == 127: + while len(buf) < pos + 8: + buf += sock.recv(4096) + length = int.from_bytes(buf[pos:pos+8], 'big'); pos += 8 + mask = None + if b2 >> 7: + while len(buf) < pos + 4: + buf += sock.recv(4096) + mask = buf[pos:pos+4]; pos += 4 + while len(buf) < pos + length: + buf += sock.recv(4096) + payload = bytearray(buf[pos:pos+length]) + if mask: + for i in range(len(payload)): + payload[i] ^= mask[i % 4] + return json.loads(payload.decode()) + finally: + sock.close() + +def fetch_service_stats(): + data = fetch_json(SERVICE_URL) + stats = data.get('data', {}).get('cycle_transfer_stats', {}) if data.get('success') else {} + by_sid = {} + for item in stats.values(): + names = item.get('server_name') or {} + transfers = item.get('transfer') or {} + nexts = item.get('next_update') or {} + for sid in names: + try: + sid_i = int(sid) + except Exception: + continue + by_sid[sid_i] = { + 'traffic_name': item.get('name'), + 'cycle_from': item.get('from'), + 'cycle_to': item.get('to'), + 'traffic_max': item.get('max'), + 'traffic_used': transfers.get(sid), + 'next_update': nexts.get(sid), + } + return by_sid + +def main(): + if not PANEL: + raise SystemExit('NEZHA_URL is not configured') + try: + source_servers = fetch_admin_servers() + source = f'nezha:{PANEL} admin api + service api; pruned absent servers' + now = int(time.time() * 1000) + online = None + except Exception as e: + print(f'admin api failed, falling back to public websocket: {e}') + ws = ws_read_one() + source_servers = ws.get('servers', []) + source = f'nezha:{PANEL} public websocket + service api; pruned absent servers' + now = ws.get('now') + online = ws.get('online') + + traffic = fetch_service_stats() + if OUT.exists(): + backup = OUT.with_name(f'{OUT.name}.bak-{datetime.now().strftime("%Y%m%d-%H%M%S")}') + shutil.copy2(OUT, backup) + + old_by_id = {} + if OUT.exists(): + try: + old_data = json.loads(OUT.read_text() or '{}') + old_by_id = {int(x['nezha_id']): x for x in old_data.get('servers', []) if x.get('nezha_id') is not None} + except Exception: + old_by_id = {} + + servers = [] + online_count = 0 + for s in source_servers: + sid = int(s['id']) + host = s.get('host') or {} + state = s.get('state') or {} + geoip = s.get('geoip') or {} + ip = geoip.get('ip') or {} + if s.get('last_active'): + online_count += 1 + old = old_by_id.get(sid, {}) + old_ssh = old.get('ssh') or {} + item = { + 'name': s.get('name'), + 'host': ip.get('ipv4_addr'), + 'ipv6': ip.get('ipv6_addr'), + 'port': old_ssh.get('port') or old.get('port') or DEFAULT_PORT, + 'user': old_ssh.get('user') or old.get('user') or DEFAULT_USER, + 'key': old_ssh.get('key') or old.get('key') or DEFAULT_KEY, + 'role': 'nezha', + 'risk': 'normal', + 'source': f'nezha:{PANEL}', + 'nezha_id': sid, + 'country': geoip.get('country_code') or s.get('country_code'), + 'platform': host.get('platform'), + 'platform_version': host.get('platform_version'), + 'arch': host.get('arch'), + 'virtualization': host.get('virtualization'), + 'cpu': ', '.join(host.get('cpu') or []), + 'mem_total': host.get('mem_total'), + 'disk_total': host.get('disk_total'), + 'display_index': s.get('display_index'), + 'last_active': s.get('last_active'), + 'uuid': s.get('uuid'), + 'public_note': s.get('public_note'), + 'state': state, + } + password = old_ssh.get('password') or old.get('password') + auth = old_ssh.get('auth') or old.get('auth') + if password: + item['password'] = password + if auth: + item['auth'] = auth + aliases = ALIASES.get(str(sid)) or ALIASES.get(sid) + if aliases: + item['aliases'] = aliases + item.update(traffic.get(sid, {})) + servers.append(item) + + data = { + 'updated_at': now_iso(), + 'source': source, + 'now': now, + 'online': online if online is not None else online_count, + 'servers': servers, + } + OUT.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n') + print(f'updated {OUT}: {len(servers)} servers') + for s in servers: + print(f"{s['nezha_id']:>3} {s['name']:<18} {s.get('host') or '':<15} {s.get('last_active')}") + +if __name__ == '__main__': + main() diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..d9f2562 --- /dev/null +++ b/release.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'HELP' +Usage: ./release.sh "release notes" + +Example: + ./release.sh 0.1.2 "修复脚本更新检测并补充部署文档。" + +The script will: + 1. check that the git working tree is clean; + 2. update version files when they exist; + 3. prepend CHANGELOG.md; + 4. commit, tag and push to GitHub. +HELP +} + +version="${1:-}" +notes="${2:-}" +if [[ -z "$version" || -z "$notes" || "$version" == "-h" || "$version" == "--help" ]]; then + usage + exit 1 +fi +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "version must be semantic version like 0.1.2" >&2 + exit 1 +fi +if [[ -n "$(git status --porcelain)" ]]; then + echo "working tree is not clean" >&2 + git status --short + exit 1 +fi + +python3 - "$version" "$notes" <<'PYHELP' +import datetime, json, re, sys +from pathlib import Path + +version, notes = sys.argv[1], sys.argv[2] +root = Path('.') + +if (root / 'version.txt').exists(): + (root / 'version.txt').write_text(version + '\n') + +for path in root.glob('*.sh'): + text = path.read_text() + text = re.sub(r'^(VERSION=")[0-9]+\.[0-9]+\.[0-9]+(".*)$', rf'\g<1>{version}\g<2>', text, flags=re.M) + text = re.sub(r'^(SCRIPT_VERSION=")[0-9]+\.[0-9]+\.[0-9]+(".*)$', rf'\g<1>{version}\g<2>', text, flags=re.M) + path.write_text(text) + +for name in ['package.json', 'package-lock.json']: + p = root / name + if p.exists(): + data = json.loads(p.read_text()) + data['version'] = version + if name == 'package-lock.json' and 'packages' in data and '' in data['packages']: + data['packages']['']['version'] = version + p.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n') + +bot = root / 'telegram-bot' / 'bot.py' +if bot.exists(): + text = bot.read_text() + text = re.sub(r"GUKO_VERSION = os\.environ\.get\('GUKO_VERSION', '[^']+'\)\.strip\(\) or '[^']+'", f"GUKO_VERSION = os.environ.get('GUKO_VERSION', '{version}').strip() or '{version}'", text) + bot.write_text(text) + +dockerfile = root / 'telegram-bot' / 'Dockerfile' +if dockerfile.exists(): + text = dockerfile.read_text() + text = re.sub(r'^ARG GUKO_VERSION=.*$', f'ARG GUKO_VERSION={version}', text, flags=re.M) + dockerfile.write_text(text) + +env = root / '.env.example' +if env.exists(): + text = env.read_text() + text = re.sub(r'^GUKO_VERSION=.*$', f'GUKO_VERSION={version}', text, flags=re.M) + env.write_text(text) + +changelog = root / 'CHANGELOG.md' +entry = f"## [{version}] - {datetime.date.today().isoformat()}\n\n- {notes}\n\n" +if changelog.exists(): + text = changelog.read_text() + if f'## [{version}]' not in text: + if text.startswith('# Changelog\n\n'): + text = '# Changelog\n\n' + entry + text[len('# Changelog\n\n'):] + else: + text = '# Changelog\n\n' + entry + text + changelog.write_text(text) +else: + changelog.write_text('# Changelog\n\n' + entry) +PYHELP + +git add -A +git commit -m "Release v$version" +git tag -a "v$version" -m "v$version" +git push +git push origin "v$version" diff --git a/scripts/security-scan.sh b/scripts/security-scan.sh new file mode 100755 index 0000000..0941f68 --- /dev/null +++ b/scripts/security-scan.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail=0 +scan() { + local name="$1" pattern="$2" + shift 2 + if git grep -nIE "$pattern" -- "$@"; then + echo "::error::Potential $name leak detected" >&2 + fail=1 + fi +} + +common_excludes=( + ':!.git' + ':!node_modules' + ':!package-lock.json' + ':!README.md' + ':!README.en.md' + ':!CHANGELOG.md' + ':!scripts/security-scan.sh' + ':!.github/workflows/ci.yml' +) + +scan 'private key' 'BEGIN (RSA |OPENSSH |EC |DSA |)PRIVATE KEY' . "${common_excludes[@]}" +scan 'Telegram bot token' '[0-9]{8,}:[A-Za-z0-9_-]{30,}' . "${common_excludes[@]}" +scan 'environment secret assignment' '(BOT_TOKEN|CF_API_TOKEN|GITHUB_TOKEN|GH_TOKEN|API_KEY|SECRET|PASSWORD|PASSWD)=([A-Za-z0-9_./+-]{12,})' . "${common_excludes[@]}" ':!.env.example' ':!*.example' ':!servers.example.json' + +if [ "$fail" -ne 0 ]; then + exit 1 +fi + +echo "security scan passed" diff --git a/servers.example.json b/servers.example.json new file mode 100644 index 0000000..4a4d52e --- /dev/null +++ b/servers.example.json @@ -0,0 +1,43 @@ +{ + "defaults": { + "ssh": { + "user": "root", + "port": 22, + "key": "~/.ssh/id_ed25519" + } + }, + "servers": [ + { + "name": "hk-01", + "host": "203.0.113.10", + "aliases": ["香港1"], + "role": "vps" + }, + { + "name": "jp-custom-port", + "host": "203.0.113.20", + "ssh": { + "user": "debian", + "port": 2222, + "key": "~/.ssh/jp_ed25519" + } + }, + { + "name": "sg-password", + "host": "203.0.113.30", + "ssh": { + "user": "root", + "port": 22, + "auth": "password", + "password": "change-me" + } + }, + { + "name": "legacy-compatible", + "host": "203.0.113.40", + "user": "root", + "port": 53580, + "key": "/data/keys/legacy_key" + } + ] +} diff --git a/telegram-bot/Dockerfile b/telegram-bot/Dockerfile new file mode 100644 index 0000000..6244a27 --- /dev/null +++ b/telegram-bot/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13-slim +WORKDIR /app +ARG GUKO_VERSION=0.1.24 +ENV GUKO_VERSION=${GUKO_VERSION} +LABEL org.opencontainers.image.version=${GUKO_VERSION} +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-client sshpass ca-certificates fonts-dejavu-core fonts-noto-cjk curl gnupg librsvg2-bin chromium \ + && curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* +COPY telegram-bot/requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt +RUN npm install -g playwright@1.59.1 +ENV NODE_PATH=/usr/lib/node_modules PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY auth.py guko.py jiaoops.py /app/ +RUN mkdir -p /data/keys /data/media /data/tmp +COPY telegram-bot/bot.py telegram-bot/render_checkplace.py /app/ +COPY telegram-bot/tools/ /app/tools/ +ENV BGP_FETCH=/app/tools/bgp_fetch.py IPPURE_DOWNLOAD=/app/tools/download_ippure.js +CMD ["python", "/app/bot.py"] diff --git a/telegram-bot/bot.py b/telegram-bot/bot.py new file mode 100755 index 0000000..2eeef95 --- /dev/null +++ b/telegram-bot/bot.py @@ -0,0 +1,3942 @@ +#!/usr/bin/env python3 +import asyncio +import html +import json +import os +import select +import signal +import re +import subprocess +import tempfile +import time +from datetime import datetime +import urllib.request +import shutil +import shlex +import socket +from pathlib import Path +from typing import Iterable +from collections import OrderedDict +from urllib.parse import urlparse +import urllib.error +from PIL import Image, ImageDraw, ImageFont + +from auth import inventory_defaults, resolve_ssh, scp_from_args, ssh_args as build_ssh_args + +from telegram import BotCommand, CopyTextButton, InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.constants import ChatAction, ParseMode +from telegram.error import BadRequest +from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters + +GUKO_VERSION = os.environ.get('GUKO_VERSION', '0.1.24').strip() or '0.1.24' +DATA_DIR = Path(os.environ.get('DATA_DIR', '/data')) +SERVERS_JSON = Path(os.environ.get('GUKO_INV') or os.environ.get('VPSPILOT_INV') or DATA_DIR / 'servers.json') +KULIN_BASE_URL = os.environ.get('KULIN_BASE_URL') or os.environ.get('KOMARI_BASE_URL') or '' +KULIN_USERNAME = os.environ.get('KULIN_USERNAME') or os.environ.get('KOMARI_USERNAME') or '' +KULIN_PASSWORD = os.environ.get('KULIN_PASSWORD') or os.environ.get('KOMARI_PASSWORD') or '' +KULIN_API_CACHE_TTL = int(os.environ.get('KULIN_API_CACHE_TTL', '300')) +KULIN_GEO_CACHE = {'ts': 0.0, 'by_ip': {}} +MEDIA_DIR = Path(os.environ.get('MEDIA_DIR', DATA_DIR / 'media')) +TMP_DIR = Path(os.environ.get('TMP_DIR', DATA_DIR / 'tmp')) +KEYS_DIR = Path(os.environ.get('KEYS_DIR', DATA_DIR / 'keys')) +RENDER_CHECKPLACE = Path(os.environ.get('RENDER_CHECKPLACE', '/app/render_checkplace.py')) +BGP_FETCH = Path(os.environ.get('BGP_FETCH', DATA_DIR / 'tools/bgp_fetch.py')) +IPPURE_DOWNLOAD = Path(os.environ.get('IPPURE_DOWNLOAD', DATA_DIR / 'tools/download_ippure.js')) +BGP_OUT_ROOT = Path(os.environ.get('BGP_OUT_ROOT', MEDIA_DIR / 'guko-bgp')) +IPPURE_TMP_ROOT = Path(os.environ.get('IPPURE_TMP_ROOT', TMP_DIR / 'guko-ippure')) +BOT_TOKEN = os.environ.get('BOT_TOKEN', '').strip() +ALLOWED_USERS = {x.strip() for x in os.environ.get('ALLOWED_USERS', '').split(',') if x.strip()} +ADMIN_USERS = {x.strip() for x in os.environ.get('ADMIN_USERS', '').split(',') if x.strip()} or set(ALLOWED_USERS) +ALLOW_INSECURE_STARTUP = os.environ.get('ALLOW_INSECURE_STARTUP', 'false').strip().lower() in ('1', 'true', 'yes', 'on') +SCRIPT_SOURCES = { + 'nexttrace': ('NextTrace', 'https://github.com/nxtrace/NTrace-core'), + 'stream': ('RegionRestrictionCheck', 'https://github.com/lmc999/RegionRestrictionCheck'), + 'ipq': ('Check.Place', 'https://github.com/xykt/NetQuality'), + 'nq': ('NodeQuality / Check.Place', 'https://github.com/xykt/NodeQuality'), + 'ss': ('SS-Rust-Manager', 'https://github.com/shuijiao1/SS-Rust-Manager'), + 'anytls': ('AnyTLS-Manager', 'https://github.com/shuijiao1/AnyTLS-Manager'), + 'vless': ('Xray-VLESS-Manager', 'https://github.com/shuijiao1/Xray-VLESS-Manager'), + 'snell': ('Snell-Manager', 'https://github.com/shuijiao1/Snell-Manager'), +} +PROXY_TOOLS = { + 'ss': { + 'name': 'SS-Rust', + 'service': 'ss-rust', + 'script_url': 'https://ss.shuijiao.de', + 'install_arg': 'install', + 'view_arg': 'view', + 'button': '🔐 SS', + }, + 'anytls': { + 'name': 'AnyTLS', + 'service': 'anytls', + 'script_url': 'https://anytls.shuijiao.de', + 'install_arg': 'install', + 'view_arg': 'view', + 'button': '🛡 AnyTLS', + }, + 'vless': { + 'name': 'VLESS', + 'service': 'xray', + 'script_url': 'https://xray.shuijiao.de', + 'install_arg': 'install', + 'view_arg': 'view', + 'button': '⚡ VLESS', + }, + 'snell': { + 'name': 'Snell', + 'service': 'snell', + 'script_url': 'https://snell.shuijiao.de', + 'install_arg': 'install', + 'view_arg': 'view', + 'button': '🌀 Snell', + }, +} + +GB5_VERSION = '5.5.1' +GB5_URL = f'https://cdn.geekbench.com/Geekbench-{GB5_VERSION}-Linux.tar.gz' +JOBS = {} +RUNNING = set() +PENDING_NEXTTRACE = {} +ADD_SESSIONS = {} +HISTORY_JSON = Path(os.environ.get('HISTORY_JSON', DATA_DIR / 'history.json')) +RESULTS_DIR = Path(os.environ.get('RESULTS_DIR', DATA_DIR / 'results')) +# Each server/test kind only keeps the latest finished result; this stays intentionally small. +HISTORY_LIMIT = int(os.environ.get('HISTORY_LIMIT', '500')) + + +def startup_check(): + problems = [] + if not BOT_TOKEN or BOT_TOKEN in {'123456:replace-me', '123:abc', 'CHANGE_ME'}: + problems.append('BOT_TOKEN is empty or still an example value') + if not ALLOWED_USERS: + problems.append('ALLOWED_USERS is empty; GUKO requires whitelist mode') + if '*' in ALLOWED_USERS or '0' in ALLOWED_USERS: + problems.append('ALLOWED_USERS contains unsafe wildcard-like value') + for d in (DATA_DIR, MEDIA_DIR, TMP_DIR, RESULTS_DIR): + d.mkdir(parents=True, exist_ok=True) + KEYS_DIR.mkdir(parents=True, exist_ok=True) + try: + os.chmod(KEYS_DIR, 0o700) + for key_file in KEYS_DIR.iterdir(): + if key_file.is_file(): + os.chmod(key_file, 0o600) + except Exception as e: + problems.append(f'failed to tighten key permissions: {e}') + HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True) + if SERVERS_JSON.exists(): + try: + inv = json.loads(SERVERS_JSON.read_text() or '{}') + leaked = [] + blob = json.dumps(inv, ensure_ascii=False) + for marker in ('BOT_TOKEN', 'CHANGE_ME', 'PRIVATE KEY'): + if marker in blob: + leaked.append(marker) + if leaked: + problems.append('servers.json appears to contain private/example markers: ' + ', '.join(leaked)) + except Exception as e: + problems.append(f'cannot parse servers inventory: {e}') + if problems and not ALLOW_INSECURE_STARTUP: + raise SystemExit('安全启动检查失败:\n- ' + '\n- '.join(problems) + '\n\n请配置 .env;如确实要临时跳过,设置 ALLOW_INSECURE_STARTUP=true') + if problems: + print('WARNING: insecure startup allowed:\n- ' + '\n- '.join(problems), flush=True) + + +def allowed(update: Update) -> bool: + user = update.effective_user + return bool(user and str(user.id) in ALLOWED_USERS) + + +def is_admin(update: Update) -> bool: + user = update.effective_user + return bool(user and str(user.id) in ADMIN_USERS) + + +async def guard(update: Update) -> bool: + if allowed(update): + return True + user = update.effective_user + uid = user.id if user else 'unknown' + if update.callback_query: + await update.callback_query.answer('无权限', show_alert=True) + elif update.effective_message: + await update.effective_message.reply_text(f'无权限使用这个GUKO bot。你的 ID:{uid}') + return False + + +async def admin_guard(update: Update) -> bool: + if not await guard(update): + return False + if is_admin(update): + return True + if update.callback_query: + await update.callback_query.answer('需要管理员权限', show_alert=True) + elif update.effective_message: + await update.effective_message.reply_text('需要管理员权限。') + return False + + +def load_inventory() -> dict: + if not SERVERS_JSON.exists(): + inv = { + 'updated_at': datetime.now().astimezone().isoformat(timespec='seconds'), + 'source': 'local', + 'defaults': { + 'ssh': { + 'user': os.environ.get('GUKO_DEFAULT_USER') or os.environ.get('VPSPILOT_DEFAULT_USER') or os.environ.get('JIAOOPS_DEFAULT_USER', 'root'), + 'port': int(os.environ.get('GUKO_DEFAULT_PORT') or os.environ.get('VPSPILOT_DEFAULT_PORT') or os.environ.get('JIAOOPS_DEFAULT_PORT', '22')), + 'key': os.environ.get('GUKO_DEFAULT_KEY') or os.environ.get('VPSPILOT_DEFAULT_KEY') or os.environ.get('JIAOOPS_DEFAULT_KEY', '/data/keys/id_ed25519'), + } + }, + 'servers': [], + } + DATA_DIR.mkdir(parents=True, exist_ok=True) + SERVERS_JSON.write_text(json.dumps(inv, ensure_ascii=False, indent=2) + '\n') + return inv + return json.loads(SERVERS_JSON.read_text()) + + +def save_inventory(inv: dict): + DATA_DIR.mkdir(parents=True, exist_ok=True) + if SERVERS_JSON.exists(): + backup = SERVERS_JSON.with_name(f'{SERVERS_JSON.name}.bak-{datetime.now().strftime("%Y%m%d-%H%M%S")}') + shutil.copy2(SERVERS_JSON, backup) + SERVERS_JSON.write_text(json.dumps(inv, ensure_ascii=False, indent=2) + '\n') + + +def next_manual_id(servers): + used = set() + for s in servers: + try: + used.add(int(server_id(s))) + except Exception: + pass + n = -1 + while n in used: + n -= 1 + return n + + +def redact_inventory(inv: dict): + def clean_server(s): + out = json.loads(json.dumps(s, ensure_ascii=False)) + ssh = out.get('ssh') or {} + if 'password' in ssh: + ssh['password'] = '***' + if out.get('password'): + out['password'] = '***' + if 'key' in ssh and ssh.get('key'): + ssh['key'] = str(ssh['key']).replace(str(DATA_DIR), '/data') + if out.get('key'): + out['key'] = str(out['key']).replace(str(DATA_DIR), '/data') + out['ssh'] = ssh + return out + data = {k: v for k, v in inv.items() if k != 'servers'} + defaults = json.loads(json.dumps(data.get('defaults') or {}, ensure_ascii=False)) + dssh = defaults.get('ssh') or {} + if 'password' in dssh: + dssh['password'] = '***' + defaults['ssh'] = dssh + data['defaults'] = defaults + data['servers'] = [clean_server(s) for s in inv.get('servers', [])] + return data + + +def server_id(s): + for key in ('id', 'legacy_id', 'nezha_id'): + if s.get(key) is not None: + return s.get(key) + host = str(s.get('host') or '').strip() + if host: + port = (s.get('ssh') or {}).get('port') or s.get('port') + if port: + safe_host = re.sub(r'[^A-Za-z0-9_.-]+', '_', host).strip('_') + return f'{safe_host}-{port}' + return host + return s.get('name') + +def update_server_by_id(sid: str, patch: dict): + inv = load_inventory() + servers = inv.get('servers') or [] + for i, s in enumerate(servers): + if str(server_id(s)) == str(sid): + merged = dict(s) + ssh = dict(merged.get('ssh') or {}) + for k in ('name', 'host', 'aliases', 'role', 'specs'): + if k in patch: + merged[k] = patch[k] + if 'ssh' in patch: + ssh.update(patch['ssh']) + merged['ssh'] = ssh + servers[i] = merged + inv['updated_at'] = datetime.now().astimezone().isoformat(timespec='seconds') + save_inventory(inv) + return merged + return None + + +def delete_server_by_id(sid: str): + inv = load_inventory() + servers = inv.get('servers') or [] + kept = [] + removed = None + for s in servers: + if str(server_id(s)) == str(sid): + removed = s + else: + kept.append(s) + if removed is None: + return None + inv['servers'] = kept + inv['updated_at'] = datetime.now().astimezone().isoformat(timespec='seconds') + save_inventory(inv) + return removed + + +def upsert_server(item: dict): + inv = load_inventory() + servers = inv.setdefault('servers', []) + q = {str(x).lower() for x in [item.get('name'), str(item.get('id') or item.get('legacy_id') or '')] if x} + q.update(str(x).lower() for x in (item.get('aliases') or [])) + item_host = str(item.get('host') or '').lower() + item_port = (item.get('ssh') or {}).get('port') or item.get('port') + replaced = False + for i, old in enumerate(servers): + fields = {str(x).lower() for x in [old.get('name'), str(old.get('id') or old.get('legacy_id') or '')] if x} + fields.update(str(x).lower() for x in (old.get('aliases') or [])) + old_host = str(old.get('host') or '').lower() + old_port = (old.get('ssh') or {}).get('port') or old.get('port') + same_endpoint = item_host and old_host == item_host and (not item_port or not old_port or str(item_port) == str(old_port)) + if (q & fields) or same_endpoint: + item.setdefault('id', old.get('id') or old.get('legacy_id') or next_manual_id(servers)) + item.setdefault('state', old.get('state') or {}) + merged = dict(old) + merged.update(enrich_server_geo(item)) + servers[i] = merged + replaced = True + break + if not replaced: + item.setdefault('id', next_manual_id(servers)) + item.setdefault('role', 'manual') + item.setdefault('source', 'local-manual') + item.setdefault('state', {}) + servers.append(enrich_server_geo(item)) + inv['updated_at'] = datetime.now().astimezone().isoformat(timespec='seconds') + save_inventory(inv) + return item, 'updated' if replaced else 'added' + + +def ssh_config(s): + return resolve_ssh(s, load_inventory()) + + +def fmt_bytes(n): + if n is None: + return '-' + n = float(n) + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if abs(n) < 1024: + return f'{n:.1f}{unit}' + n /= 1024 + return f'{n:.1f}PB' + + +def pct_num(used, total): + if used is None or total in (None, 0): + return None + return float(used) / float(total) * 100 + + +def pct(used, total): + p = pct_num(used, total) + return '-' if p is None else f'{p:.1f}%' + + +def meter(value, total=100, width=22): + p = pct_num(value, total) + if p is None: + return '▕' + '▱' * width + '▏', '-' + p = max(0, min(100, p)) + filled = max(0, min(width, round(p / 100 * width))) + if p >= 85: + icon = '🔴' + elif p >= 65: + icon = '🟠' + else: + icon = '🟢' + # Braille/box chars render cleaner in Telegram than block+shade when wrapped in . + bar_text = '▕' + '▰' * filled + '▱' * (width - filled) + '▏' + return bar_text, f'{icon} {p:.1f}%' + + +def usage_block(label, emoji, used, total): + b, p = meter(used, total) + return f'{emoji} {label} {p}\n{b}\n{fmt_bytes(used)} / {fmt_bytes(total)}' + + +def cpu_block(cpu, width=22): + b, p = meter(float(cpu or 0), 100, width) + return f'🧠 CPU {p}\n{b}' + + +def fmt_duration(seconds): + if seconds is None: + return '-' + seconds = int(seconds) + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes, _ = divmod(rem, 60) + if days: + return f'{days}天 {hours}小时' + if hours: + return f'{hours}小时 {minutes}分' + return f'{minutes}分' + + +def short_cpu_name(name): + if not name: + return '-' + text = str(name).replace('(R)', '').replace('(TM)', '') + text = ' '.join(text.split()) + return text[:58] + ('…' if len(text) > 58 else '') + + +IPV4_RE = re.compile(r'(?:^|\D)((?:\d{1,3}\.){3}\d{1,3})(?:\D|$)') +DOMAIN_RE = re.compile(r'^(?:https?://)?(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:[/?#].*)?$') + + +def is_ipv4(value): + m = IPV4_RE.search(str(value or '')) + if not m: + return False + try: + parts = [int(x) for x in m.group(1).split('.')] + return len(parts) == 4 and all(0 <= x <= 255 for x in parts) + except Exception: + return False + + +def extract_ipv4(value): + m = IPV4_RE.search(str(value or '')) + return m.group(1) if m and is_ipv4(m.group(1)) else '' + + +def normalize_domain(value): + text = str(value or '').strip() + if not text or text.startswith('/'): + return '' + first = text.split()[0].strip() + if extract_ipv4(first): + return '' + if not DOMAIN_RE.match(first): + return '' + parsed = urlparse(first if '://' in first else '//' + first) + host = (parsed.hostname or '').strip().rstrip('.') + return host if re.match(r'^[A-Za-z0-9.-]+$', host) else '' + + +def safe_target(value): + text = str(value or '').strip() + text = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', text).split('/')[0].split('?')[0].replace(':', '_') + text = re.sub(r'[^A-Za-z0-9_.-]+', '_', text).strip('_') + return text or 'target' + + +def country_flag(code): + code = (code or '').strip().upper() + if code == 'TW': + code = 'CN' + if len(code) != 2 or not code.isalpha(): + return '🌐' + return chr(0x1F1E6 + ord(code[0]) - ord('A')) + chr(0x1F1E6 + ord(code[1]) - ord('A')) + + +def kulin_api_request(path, *, timeout=4): + if not (KULIN_USERNAME and KULIN_PASSWORD): + return None + if not KULIN_BASE_URL: + return None + base = KULIN_BASE_URL.rstrip('/') + cookie = urllib.request.HTTPCookieProcessor() + opener = urllib.request.build_opener(cookie) + try: + login_body = json.dumps({'username': KULIN_USERNAME, 'password': KULIN_PASSWORD}).encode() + login_req = urllib.request.Request( + base + '/api/v1/login', + data=login_body, + headers={'Content-Type': 'application/json', 'User-Agent': 'GUKO/1.0'}, + ) + with opener.open(login_req, timeout=timeout) as resp: + json.loads(resp.read().decode(errors='replace') or '{}') + req = urllib.request.Request(base + path, headers={'User-Agent': 'GUKO/1.0'}) + with opener.open(req, timeout=timeout) as resp: + return json.loads(resp.read().decode(errors='replace') or '{}') + except Exception: + return None + + +def flatten_kulin_servers(payload): + data = (payload or {}).get('data') + if isinstance(data, dict): + for key in ('servers', 'list', 'items', 'records'): + if isinstance(data.get(key), list): + return data.get(key) + if isinstance(data, list): + return data + return [] + + +def ip_from_kulin_host(item): + if not isinstance(item, dict): + return '' + geoip = item.get('geoip') + if isinstance(geoip, dict): + geo_ip = geoip.get('ip') + if isinstance(geo_ip, dict): + found = extract_ipv4(geo_ip.get('ipv4_addr') or geo_ip.get('IP') or geo_ip.get('ip') or '') + if found: + return found + found = extract_ipv4(geoip.get('ip') or geoip.get('ipv4') or '') + if found: + return found + host = item.get('host') + if isinstance(host, dict): + return extract_ipv4(host.get('IP') or host.get('ip') or host.get('ipv4') or '') + return extract_ipv4(item.get('ip') or item.get('ipv4') or item.get('host') or '') + + +def country_from_kulin_item(item): + if not isinstance(item, dict): + return '' + geoip = item.get('geoip') + candidates = [] + if isinstance(geoip, dict): + candidates.extend([geoip.get('country_code'), geoip.get('country'), geoip.get('region')]) + candidates.extend([item.get('region'), item.get('country'), item.get('country_code')]) + host = item.get('host') + if isinstance(host, dict): + candidates.extend([host.get('CountryCode'), host.get('country_code'), host.get('country')]) + for val in candidates: + text = str(val or '').strip().lower() + if len(text) == 2 and text.isalpha(): + return text + return '' + + +def kulin_geo_map(): + now = time.time() + if now - float(KULIN_GEO_CACHE.get('ts') or 0) < KULIN_API_CACHE_TTL: + return KULIN_GEO_CACHE.get('by_ip') or {} + by_ip = {} + payload = kulin_api_request('/api/v1/server') + for item in flatten_kulin_servers(payload): + ip = ip_from_kulin_host(item) + code = country_from_kulin_item(item) + if ip and code: + by_ip[ip] = code + KULIN_GEO_CACHE['ts'] = now + KULIN_GEO_CACHE['by_ip'] = by_ip + return by_ip + + +def geolocate_host(host, timeout=4): + ip = extract_ipv4(host or '') + if not ip: + return None + # Keep GUKO's country flags aligned with Kulin/Komari. Its panel writes + # server.region from the currently configured GeoIP provider, so prefer + # that over ad-hoc public web APIs. + code = kulin_geo_map().get(ip) + if code: + return code + providers = [ + (f'http://ip-api.com/json/{ip}?fields=status,countryCode,query,message', lambda d: d.get('countryCode') if d.get('status') == 'success' else None), + (f'https://ipinfo.io/{ip}/json', lambda d: d.get('country')), + (f'https://get.geojs.io/v1/ip/country/{ip}.json', lambda d: d.get('country')), + ] + for url, pick in providers: + try: + req = urllib.request.Request(url, headers={'User-Agent': 'GUKO/1.0'}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode(errors='replace')) + code = pick(data) + if code: + return str(code).lower() + except Exception: + continue + return None + + +def enrich_server_geo(item): + if item.get('country'): + item['country'] = str(item.get('country')).lower() + return item + code = geolocate_host(item.get('host')) + if code: + item['country'] = code + return item + + +ANSI_RE = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]') + + +def strip_ansi(text): + return ANSI_RE.sub('', text or '') + + +def safe(s): + return html.escape(str(s)) if s is not None else '-' + + +def running_text(s, task): + return f'正在运行中:{safe(s.get("name"))} {task}' + + +async def send_running_notice(bot, chat_id, s, task): + await bot.send_message(chat_id, running_text(s, task), parse_mode=ParseMode.HTML) + + +async def bot_task_started_notice(bot, chat_id, s, task, started=True): + if started: + await bot.send_message(chat_id, running_text(s, task), parse_mode=ParseMode.HTML) + else: + await bot.send_message(chat_id, f'这个任务已经在运行中:{safe(s.get("name"))} {task}', parse_mode=ParseMode.HTML) + + +def is_valid_hostname(value): + text = str(value or '').strip() + if not text or len(text) > 253 or ' ' in text: + return False + if is_ipv4(text): + return True + return bool(re.match(r'^[A-Za-z0-9.-]+$', text) and '.' in text) + + +def parse_host_port(text): + raw = str(text or '').strip() + if not raw: + return '', None + if raw.count(':') == 1 and not raw.startswith('['): + host, port = raw.rsplit(':', 1) + if port.isdigit(): + return host.strip(), int(port) + return raw, None + + +def source_repo(kind): + return SCRIPT_SOURCES.get(kind, ('', ''))[1] + + +def script_command_text(kind, **kwargs): + if kind == 'nexttrace': + target = kwargs.get('target') or '<目标IP或域名>' + return ( + '脚本命令:\n' + 'curl -sL https://nxtrace.org/nt | bash\n' + f'nexttrace {target}' + ) + if kind == 'stream': + region_id = kwargs.get('region_id') or '<地区编号>' + proto_arg = kwargs.get('proto_arg') or '-M 4' + return ( + '脚本命令:\n' + 'bash <(curl -L -s check.unlock.media) ' + f'{proto_arg} -R {region_id}' + ) + if kind == 'ipq': + return '脚本命令:\nbash <(curl -Ls https://IP.Check.Place) -y' + if kind in PROXY_TOOLS: + tool = PROXY_TOOLS[kind] + action = kwargs.get('action') or '' + arg = tool['install_arg'] if action in ('install', 'ensure') else tool['view_arg'] + if kind == 'vless' and action in ('install', 'ensure'): + mode = kwargs.get('mode') or '' + return f"脚本命令:\nbash <(curl -Ls {tool['script_url']}) # 选择 {mode}" + return f"脚本命令:\nbash <(curl -Ls {tool['script_url']}) {arg}" + if kind == 'nq': + selected = kwargs.get('selected') + ip_mode = kwargs.get('ip_mode') + extra = '' + if selected or ip_mode: + extra = f'\n选择:{selected or "-"};{ip_mode or "-"}' + return '脚本命令:\nbash <(curl -sL https://run.NodeQuality.com)' + extra + return '' + + +def script_command_html(kind, **kwargs): + return safe(script_command_text(kind, **kwargs)) + + +def find_server(name: str, servers: Iterable[dict]): + q = name.lower() + for s in servers: + fields = [s.get('name'), s.get('host'), str(server_id(s))] + fields += s.get('aliases') or [] + if any((f or '').lower() == q for f in fields): + return s + for s in servers: + fields = [s.get('name'), s.get('host')] + (s.get('aliases') or []) + if any(q in (f or '').lower() for f in fields): + return s + return None + + +def find_server_by_id(sid: str): + return find_server(sid, load_inventory().get('servers', [])) + + +def server_button_label(s): + return f"{country_flag(s.get('country'))} {s.get('name')}" + + +def main_menu_markup(): + servers = load_inventory().get('servers', []) + rows = [] + for i in range(0, len(servers), 2): + rows.append([ + InlineKeyboardButton(server_button_label(s), callback_data=f"srv:{server_id(s)}") + for s in servers[i:i+2] + ]) + rows.append([ + InlineKeyboardButton('➕ 添加服务器', callback_data='add:start'), + InlineKeyboardButton('📥 批量导入', callback_data='add:bulk'), + ]) + return InlineKeyboardMarkup(rows) + + +def server_has_ipv6(s): + return bool(s.get('ipv6') and str(s.get('ipv6')).strip() not in ('-', 'None')) + + +def add_start_markup(): + return InlineKeyboardMarkup([ + [InlineKeyboardButton('➕ 添加单台', callback_data='add:one')], + [InlineKeyboardButton('📥 批量导入', callback_data='add:bulk')], + [InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')], + ]) + + +def add_auth_markup(): + return InlineKeyboardMarkup([ + [InlineKeyboardButton('♻️ 沿用默认密钥/配置', callback_data='addauth:default')], + [InlineKeyboardButton('📁 使用已有密钥路径', callback_data='addauth:keypath')], + [InlineKeyboardButton('🔑 上传/粘贴新私钥', callback_data='addauth:key')], + [InlineKeyboardButton('🔐 使用密码', callback_data='addauth:password')], + [InlineKeyboardButton('📦 先只保存,不测试登录', callback_data='addauth:skip')], + [InlineKeyboardButton('❌ 取消', callback_data='add:cancel')], + ]) + + +def bulk_mode_markup(): + return InlineKeyboardMarkup([ + [InlineKeyboardButton('✅ 全部同一个端口', callback_data='bulkport:same')], + [InlineKeyboardButton('🧩 每台自己写端口', callback_data='bulkport:per')], + [InlineKeyboardButton('❌ 取消', callback_data='add:cancel')], + ]) + + +def bulk_auth_markup(): + return InlineKeyboardMarkup([ + [InlineKeyboardButton('🔑 全部同一把密钥', callback_data='bulkauth:key')], + [InlineKeyboardButton('🔐 全部同一个密码', callback_data='bulkauth:password')], + [InlineKeyboardButton('🧩 每台自己写认证', callback_data='bulkauth:per')], + [InlineKeyboardButton('📦 先只导入,不测试登录', callback_data='bulkauth:skip')], + [InlineKeyboardButton('❌ 取消', callback_data='add:cancel')], + ]) + + +def add_help_text(): + return ( + '➕ 添加服务器\n\n' + '可以单台添加,也可以批量导入。\n' + '支持:不同端口、不同用户名、沿用默认密钥、已有密钥路径、上传私钥、密码登录。' + ) + + +def bulk_help_text(): + return ( + '📥 批量导入服务器\n\n' + '先选端口策略,再选认证策略。\n\n' + '每行格式:\n' + '名称 IP 用户\n' + '名称 IP:端口 用户\n\n' + '如果选择“每台自己写认证”,每行可以写:\n' + '名称 IP 端口 用户 key:/data/keys/a\n' + '名称 IP 端口 用户 password:你的密码' + ) + + +def tool_enabled(name): + val = os.environ.get(f'ENABLE_{name.upper()}', 'true').strip().lower() + return val in ('1', 'true', 'yes', 'on') + + + +def button_rows(buttons, per_row=2): + return [buttons[i:i+per_row] for i in range(0, len(buttons), per_row)] + + +def server_markup(s): + sid = server_id(s) + host = s.get('host') or '' + rows = [] + if host: + rows.append([InlineKeyboardButton('📋 复制 IPv4', copy_text=CopyTextButton(host))]) + if s.get('ipv6'): + rows.append([InlineKeyboardButton('📋 复制 IPv6', copy_text=CopyTextButton(s.get('ipv6')))]) + + test_buttons = [] + if tool_enabled('ipq'): + test_buttons.append(InlineKeyboardButton('🧪 IP质量', callback_data=f'ipq:{sid}')) + if tool_enabled('nq'): + test_buttons.append(InlineKeyboardButton('📊 NodeQuality', callback_data=f'nqask:{sid}')) + if tool_enabled('gb5'): + test_buttons.append(InlineKeyboardButton('🏁 GB5', callback_data=f'gb5:{sid}')) + if tool_enabled('stream'): + test_buttons.append(InlineKeyboardButton('🎬 流媒体', callback_data=f'stream:{sid}')) + if tool_enabled('bgp'): + test_buttons.append(InlineKeyboardButton('🧭 BGP图', callback_data=f'bgp:{sid}')) + if tool_enabled('ippure'): + test_buttons.append(InlineKeyboardButton('🧼 IPPure', callback_data=f'ippure:{sid}')) + if tool_enabled('ss'): + test_buttons.append(InlineKeyboardButton(PROXY_TOOLS['ss']['button'], callback_data=f'proxy:ss:{sid}')) + if tool_enabled('anytls'): + test_buttons.append(InlineKeyboardButton(PROXY_TOOLS['anytls']['button'], callback_data=f'proxy:anytls:{sid}')) + if tool_enabled('vless'): + test_buttons.append(InlineKeyboardButton(PROXY_TOOLS['vless']['button'], callback_data=f'proxy:vless:{sid}')) + if tool_enabled('snell'): + test_buttons.append(InlineKeyboardButton(PROXY_TOOLS['snell']['button'], callback_data=f'proxy:snell:{sid}')) + rows.extend(button_rows(test_buttons, 2)) + + ops_buttons = [ + InlineKeyboardButton('📋 当前任务', callback_data=f'jobsrv:{sid}'), + InlineKeyboardButton('📜 历史记录', callback_data=f'hist:{sid}'), + InlineKeyboardButton('🧪 测试SSH', callback_data=f'testssh:{sid}'), + ] + if tool_enabled('nexttrace'): + ops_buttons.append(InlineKeyboardButton('🛣 NextTrace', callback_data=f'ntask:{sid}')) + rows.extend(button_rows(ops_buttons, 2)) + rows.extend([ + [InlineKeyboardButton('✏️ 编辑', callback_data=f'edit:{sid}'), InlineKeyboardButton('🗑 删除', callback_data=f'delask:{sid}')], + [InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')], + ]) + return InlineKeyboardMarkup(rows) + + +NQ_ITEMS = [ + ('hardware', '硬件', 'HardwareQuality', 1), + ('ip', 'IP质量', 'IPQuality', 2), + ('net', '网络', 'NetQuality', 4), + ('backroute', '回程', 'Backroute Trace', 8), +] +NQ_ALL_MASK = sum(x[3] for x in NQ_ITEMS) +NQ_DEFAULT_MASK = 0 +NQ_IP_MODES = {'4': '仅 IPv4', '46': 'IPv4 + IPv6'} + + +def confirm_nq_markup(s, mask=NQ_DEFAULT_MASK, ip_mode='4'): + sid = server_id(s) + rows = [] + for _key, label, _full, bit in NQ_ITEMS: + mark = '✅' if mask & bit else '☐' + new_mask = mask & ~bit if mask & bit else mask | bit + rows.append([InlineKeyboardButton(f'{mark} {label}', callback_data=f'nqtoggle:{sid}:{new_mask}:{ip_mode}')]) + rows.append([ + InlineKeyboardButton('全选', callback_data=f'nqsel:{sid}:{NQ_ALL_MASK}:{ip_mode}'), + InlineKeyboardButton('清空', callback_data=f'nqsel:{sid}:0:{ip_mode}'), + ]) + if server_has_ipv6(s): + rows.append([ + InlineKeyboardButton(('✅ ' if ip_mode == '4' else '☐ ') + '仅 IPv4', callback_data=f'nqproto:{sid}:{mask}:4'), + InlineKeyboardButton(('✅ ' if ip_mode == '46' else '☐ ') + 'IPv4 + IPv6', callback_data=f'nqproto:{sid}:{mask}:46'), + ]) + run_text = '✅ 开始测试' if mask != NQ_ALL_MASK else '✅ 开始全测' + rows.append([InlineKeyboardButton(run_text, callback_data=f'nqrun:{sid}:{mask}:{ip_mode}')]) + rows.append([InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')]) + return InlineKeyboardMarkup(rows) + + +def nq_selected(mask): + return [item for item in NQ_ITEMS if mask & item[3]] + + +def nq_selected_text(mask): + items = nq_selected(mask) + if not items: + return '未选择' + return ' / '.join(item[2] for item in items) + + +def nq_ip_mode_text(ip_mode): + return NQ_IP_MODES.get(str(ip_mode), NQ_IP_MODES['4']) + + +def nq_answer_script(mask): + return ''.join(('y\n' if mask & bit else 'n\n') for _key, _label, _full, bit in NQ_ITEMS) + + +def nq_remote_ipv_arg(s, ip_mode): + # NodeQuality default runs dual-stack when IPv6 exists. Force -4 for v4-only. + return '' if (ip_mode == '46' and server_has_ipv6(s)) else '-4' + + +STREAM_REGION_BY_COUNTRY = { + 'tw': ('1', '跨国 + 台湾'), + 'hk': ('2', '跨国 + 香港'), + 'mo': ('2', '跨国 + 香港'), + 'jp': ('3', '跨国 + 日本'), + 'us': ('4', '跨国 + 北美'), + 'ca': ('4', '跨国 + 北美'), + 'br': ('5', '跨国 + 南美'), + 'ar': ('5', '跨国 + 南美'), + 'cl': ('5', '跨国 + 南美'), + 'gb': ('6', '跨国 + 欧洲'), + 'uk': ('6', '跨国 + 欧洲'), + 'de': ('6', '跨国 + 欧洲'), + 'fr': ('6', '跨国 + 欧洲'), + 'nl': ('6', '跨国 + 欧洲'), + 'au': ('7', '跨国 + 大洋洲'), + 'nz': ('7', '跨国 + 大洋洲'), + 'kr': ('8', '跨国 + 韩国'), + 'sg': ('9', '跨国 + 东南亚'), + 'my': ('9', '跨国 + 东南亚'), + 'th': ('9', '跨国 + 东南亚'), + 'vn': ('9', '跨国 + 东南亚'), + 'ph': ('9', '跨国 + 东南亚'), + 'id': ('9', '跨国 + 东南亚'), + 'in': ('10', '跨国 + 印度'), + 'za': ('11', '跨国 + 非洲'), +} + + +def stream_region_for_server(s): + code = str(s.get('country') or '').lower() + if not code: + code = geolocate_host(s.get('host')) or '' + return STREAM_REGION_BY_COUNTRY.get(code, ('0', '只测跨国平台')) + + +def stream_menu_text(s): + rid, label = stream_region_for_server(s) + proto = '优先 IPv4;若无 IPv4 自动改测 IPv6' + return ( + f'🎬 准备在 {safe(s.get("name"))} 跑流媒体检测:\n\n' + f'地区选项:{safe(label)}\n' + f'协议策略:{safe(proto)}\n\n' + '会在目标机器本机执行 RegionRestrictionCheck,并把结果整理成更好读的摘要。' + ) + + +def stream_markup(s): + sid = server_id(s) + return InlineKeyboardMarkup([ + [InlineKeyboardButton('✅ 开始流媒体检测', callback_data=f'streamrun:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]) + + +def nq_menu_text(s, mask, ip_mode): + return ( + f'📊 选择要在 {safe(s.get("name"))} 跑的 NodeQuality 项目:\n\n' + f'当前项目:{safe(nq_selected_text(mask))}\n' + f'IP 协议:{safe(nq_ip_mode_text(ip_mode))}\n\n' + '点击项目进行选择/取消;全选就是完整 NodeQuality。' + ) + +def menu_text(): + d = load_inventory() + servers = d.get('servers', []) + return ( + f'GUKO v{safe(GUKO_VERSION)}\n' + f'服务器 {len(servers)} 台\n\n' + '👇 点服务器打开操作面板。' + ) + + +def server_detail_text(s): + name = safe(s.get('name')) + title = f"{country_flag(s.get('country'))} {name}" + cfg = ssh_config(s) + lines = [ + title, + f"{safe(cfg.get('host'))} · SSH {safe(cfg.get('port'))} · {safe(cfg.get('user'))}", + ] + ipv6 = s.get('ipv6') + if ipv6 and str(ipv6).strip() not in ('-', 'None'): + lines.append(f"IPv6 {safe(ipv6)}") + return '\n'.join(lines) + + +def ssh_env_for(s): + cfg = ssh_config(s) + if cfg.get('auth') == 'password' and cfg.get('password'): + env = os.environ.copy() + env['SSHPASS'] = str(cfg['password']) + return env + return None + + +async def run_cmd(args, timeout=60, env=None): + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=env, + ) + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return 124, '命令超时' + return proc.returncode, out.decode(errors='replace') + + +def ssh_args(s, remote, *, tty=False): + return build_ssh_args(s, remote, tty=tty, inv=load_inventory()) + + +async def test_server_login(s, timeout=15): + try: + code, out = await run_cmd(ssh_args(s, 'printf "ok:"; hostname', tty=False), timeout=timeout, env=ssh_env_for(s)) + return code == 0, strip_ansi(out).strip() + except FileNotFoundError as e: + return False, f'缺少依赖:{e}' + except Exception as e: + return False, str(e) + + +def server_specs_remote_script(): + return r''' +set -eu +cores=$(nproc 2>/dev/null || true) +mem_bytes=$(awk '/MemTotal:/ {print $2 * 1024; exit}' /proc/meminfo 2>/dev/null || true) +disk_bytes='' +if command -v lsblk >/dev/null 2>&1; then + disk_bytes=$(lsblk -bdn -o SIZE,TYPE 2>/dev/null | awk '$2 == "disk" {sum += $1} END {if (sum > 0) printf "%.0f", sum}') +fi +[ -n "$disk_bytes" ] || disk_bytes=$(df -B1 / 2>/dev/null | awk 'NR==2 {printf "%.0f", $2}') +printf 'CPU=%s\nMEM_BYTES=%s\nDISK_BYTES=%s\n' "$cores" "$mem_bytes" "$disk_bytes" +''' + + +def fmt_capacity_2(value): + try: + n = float(value) + except Exception: + return '' + if n <= 0: + return '' + gb = n / 1000 / 1000 / 1000 + if gb < 1: + return f'{n / 1000 / 1000:.2f}MB' + return f'{gb:.2f}GB' + + +def format_server_specs(specs): + if not isinstance(specs, dict): + return '' + parts = [] + cores = specs.get('cpu_cores') + if cores not in (None, ''): + try: + cores_text = str(int(float(cores))) + except Exception: + cores_text = str(cores) + parts.append(f'🧠 {safe(cores_text)} Cores') + mem = fmt_capacity_2(specs.get('mem_bytes')) + if mem: + parts.append(f'💾 {safe(mem)} 内存') + disk = fmt_capacity_2(specs.get('disk_bytes')) + if disk: + parts.append(f'🗄️ {safe(disk)} 硬盘') + return ' · '.join(parts) + + +def cache_server_specs(s, specs): + if not isinstance(specs, dict) or not specs: + return None + payload = dict(specs) + payload['cached_at'] = iso_now() + return update_server_by_id(str(server_id(s)), {'specs': payload}) + + +async def read_server_specs(s, timeout=8): + try: + code, out = await run_cmd(ssh_args(s, server_specs_remote_script(), tty=False), timeout=timeout, env=ssh_env_for(s)) + if code != 0: + return None + vals = {} + for raw in strip_ansi(out).splitlines(): + if '=' in raw: + k, v = raw.split('=', 1) + vals[k.strip()] = v.strip() + specs = { + 'cpu_cores': vals.get('CPU') or '', + 'mem_bytes': vals.get('MEM_BYTES') or '', + 'disk_bytes': vals.get('DISK_BYTES') or '', + } + if not any(specs.values()): + return None + cache_server_specs(s, specs) + return specs + except Exception: + return None + + +async def server_detail_text_with_specs(s): + text = server_detail_text(s) + specs_text = format_server_specs(s.get('specs')) + if not specs_text: + specs = await read_server_specs(s) + specs_text = format_server_specs(specs) + if specs_text: + text += '\n' + specs_text + return text + + +def build_server_item(name, host, user, port, auth_kind=None, password=None, key=None): + item = { + 'name': name, + 'host': host, + 'role': 'manual', + 'source': 'local-manual', + 'ssh': { + 'user': user or 'root', + 'port': int(port or 22), + }, + } + if auth_kind == 'password': + item['ssh']['auth'] = 'password' + item['ssh']['password'] = password or '' + elif auth_kind == 'key': + item['ssh']['auth'] = 'key' + item['ssh']['key'] = key or '' + elif auth_kind == 'default': + item['ssh']['auth'] = 'key' + return enrich_server_geo(item) + + +def parse_bulk_lines(text, *, same_port=None, auth_mode='skip', shared_auth=None): + items = [] + errors = [] + for lineno, raw in enumerate((text or '').splitlines(), 1): + line = raw.strip() + if not line or line.startswith('#'): + continue + try: + parts = shlex.split(line) + except Exception as e: + errors.append(f'第 {lineno} 行解析失败:{e}') + continue + if len(parts) < 2: + errors.append(f'第 {lineno} 行字段太少') + continue + name = parts[0] + host, embedded_port = parse_host_port(parts[1]) + idx = 2 + port = same_port or embedded_port + if port is None and idx < len(parts) and parts[idx].isdigit(): + port = int(parts[idx]); idx += 1 + user = 'root' + if idx < len(parts) and not parts[idx].startswith(('key:', 'password:', 'auth:')): + user = parts[idx]; idx += 1 + if not port: + port = 22 + if not is_valid_hostname(host): + errors.append(f'第 {lineno} 行 IP/域名不正确:{host}') + continue + auth_kind = None + password = None + key = None + if auth_mode == 'key': + auth_kind, key = 'key', shared_auth + elif auth_mode == 'password': + auth_kind, password = 'password', shared_auth + elif auth_mode == 'per': + for token in parts[idx:]: + if token.startswith('key:'): + auth_kind, key = 'key', token[4:] + elif token.startswith('password:'): + auth_kind, password = 'password', token[9:] + if not auth_kind: + errors.append(f'第 {lineno} 行缺少 key: 或 password:') + continue + item = build_server_item(name, host, user, port, auth_kind, password, key) + items.append(item) + return items, errors + + +def save_private_key(chat_id, content, filename='id_key'): + text = content.decode(errors='replace') if isinstance(content, bytes) else str(content or '') + if 'PRIVATE KEY' not in text: + raise ValueError('没有识别到 PRIVATE KEY 内容') + KEYS_DIR.mkdir(parents=True, exist_ok=True) + safe_name = safe_target(filename).replace('.', '_')[:40] or 'id_key' + path = KEYS_DIR / f'{chat_id}-{int(time.time())}-{safe_name}.pem' + path.write_text(text.strip() + '\n') + os.chmod(path, 0o600) + return str(path) + + +def edit_markup(s): + sid = server_id(s) + return InlineKeyboardMarkup([ + [InlineKeyboardButton('改名称', callback_data=f'editfield:{sid}:name'), InlineKeyboardButton('改主机/IP', callback_data=f'editfield:{sid}:host')], + [InlineKeyboardButton('改端口', callback_data=f'editfield:{sid}:port'), InlineKeyboardButton('改用户', callback_data=f'editfield:{sid}:user')], + [InlineKeyboardButton('改密钥路径', callback_data=f'editfield:{sid}:key'), InlineKeyboardButton('改密码', callback_data=f'editfield:{sid}:password')], + [InlineKeyboardButton('改为默认密钥', callback_data=f'editdefault:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]) + + +def add_session(chat_id, **data): + cur = ADD_SESSIONS.setdefault(chat_id, {}) + cur.update(data) + return cur + + +def clear_add_session(chat_id): + ADD_SESSIONS.pop(chat_id, None) + + +async def finish_single_add(update: Update, context: ContextTypes.DEFAULT_TYPE, sess: dict): + item = build_server_item( + sess.get('name'), sess.get('host'), sess.get('user') or 'root', sess.get('port') or 22, + sess.get('auth_kind'), sess.get('password'), sess.get('key'), + ) + saved, action = upsert_server(item) + ok_text = '未测试登录' + if sess.get('auth_kind') in ('key', 'password', 'default'): + ok, out = await test_server_login(saved) + ok_text = ('✅ 登录成功:' if ok else '⚠️ 已保存,但登录测试失败:') + safe(out[-800:]) + clear_add_session(update.effective_chat.id) + verb = '更新' if action == 'updated' else '添加' + cfg = ssh_config(saved) + await update.effective_message.reply_text( + f'✅ 服务器已{verb}:{safe(saved.get("name"))}\n' + f'{safe(cfg.get("user"))}@{safe(cfg.get("host"))}:{safe(cfg.get("port"))}\n\n' + f'{ok_text}', + parse_mode=ParseMode.HTML, + reply_markup=main_menu_markup(), + ) + + +async def finish_bulk_add(update: Update, context: ContextTypes.DEFAULT_TYPE, sess: dict, text: str): + items, errors = parse_bulk_lines( + text, + same_port=sess.get('same_port'), + auth_mode=sess.get('auth_mode') or 'skip', + shared_auth=sess.get('shared_auth'), + ) + if not items: + await update.effective_message.reply_text('没有可导入的服务器。\n' + '\n'.join(errors[:8])) + return + results = [] + for item in items: + saved, action = upsert_server(item) + results.append((saved, action)) + clear_add_session(update.effective_chat.id) + lines = [f'✅ 已导入 {len(results)} 台服务器。'] + if errors: + lines.append(f'⚠️ 跳过 {len(errors)} 行:') + lines.extend(errors[:6]) + lines.append('\n前几台:') + for saved, action in results[:8]: + cfg = ssh_config(saved) + lines.append(f'- {saved.get("name")} {cfg.get("user")}@{cfg.get("host")}:{cfg.get("port")} {action}') + await update.effective_message.reply_text('\n'.join(safe(x) for x in lines), parse_mode=ParseMode.HTML, reply_markup=main_menu_markup()) + + +def job_id(kind, s): + return f"{kind}-{server_id(s)}-{int(time.time() * 1000)}" + +KIND_NAME = { + 'ipq': 'IP质量', 'nq': 'NodeQuality', 'gb5': 'GB5', 'stream': '流媒体检测', + 'nexttrace': 'NextTrace', 'bgp': 'BGP图', 'ippure': 'IPPure图', + 'ss': 'SS', 'anytls': 'AnyTLS', +} +STATUS_ICON = {'running': '🟢', 'done': '✅', 'failed': '🔴'} + + +def iso_now(): + return datetime.now().astimezone().isoformat(timespec='seconds') + + +def kind_result_dir(s, kind): + return RESULTS_DIR / str(server_id(s)) / str(kind) + + +def latest_result_files(s, kind): + root = kind_result_dir(s, kind) + if not root.exists(): + return [] + files = [p for p in root.iterdir() if p.is_file() and p.stat().st_size > 0] + return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True) + + +def legacy_media_files(s, kind): + host = str(s.get('host') or '').strip() + files = [] + if kind == 'bgp' and host: + for base in ('guko-bgp', 'vpspilot-bgp'): + files.extend([p for p in (MEDIA_DIR / base).glob(f'*/latest-{host}.png') if p.is_file() and p.stat().st_size > 0]) + return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True) + + +def all_result_files(s, kind): + files = latest_result_files(s, kind) + return files if files else legacy_media_files(s, kind) + + +def clear_result_files(s, kind): + root = kind_result_dir(s, kind) + if not root.exists(): + return + for old in root.iterdir(): + if old.is_file(): + try: + old.unlink() + except Exception: + pass + + +def persist_result_file(s, kind, src, suffix=None, clear=True): + if not src: + return None + src = Path(src) + if not src.exists() or not src.is_file() or src.stat().st_size <= 0: + return None + root = kind_result_dir(s, kind) + root.mkdir(parents=True, exist_ok=True) + if clear: + clear_result_files(s, kind) + ext = suffix or src.suffix or '.bin' + dst = root / f'latest{ext}' + if src.resolve() != dst.resolve(): + shutil.copy2(src, dst) + return str(dst) + + +def latest_media_path(item): + paths = item.get('media_paths') or [] + for x in paths: + p = Path(x) + if p.exists() and p.is_file() and p.stat().st_size > 0: + return p + return None + + +def load_history(): + if not HISTORY_JSON.exists(): + return [] + try: + data = json.loads(HISTORY_JSON.read_text() or '[]') + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def save_history(items): + HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True) + HISTORY_JSON.write_text(json.dumps(items[-HISTORY_LIMIT:], ensure_ascii=False, indent=2) + '\n') + + +def history_append(jid, job): + item = { + 'job_id': jid, + 'server': job.get('server'), + 'server_id': job.get('server_id'), + 'kind': job.get('kind'), + 'status': job.get('status'), + 'target': job.get('target'), + 'selected': job.get('selected'), + 'ip_mode': job.get('ip_mode'), + 'region': job.get('region'), + 'started_at': job.get('started_at'), + 'completed_at': job.get('completed_at'), + 'duration_sec': job.get('duration_sec'), + 'urls': history_urls(job.get('log') or ''), + 'media_paths': job.get('media_paths') or ([] if not job.get('media_path') else [job.get('media_path')]), + 'log_tail': trim_log(strip_ansi(job.get('log') or ''), 3500), + } + hist = [ + x for x in load_history() + if not ( + x.get('job_id') == jid + or (str(x.get('server_id') or '') == str(item.get('server_id') or '') and x.get('kind') == item.get('kind')) + ) + ] + hist.append(item) + save_history(hist) + + +def create_job(s, kind, status='running', **extra): + jid = job_id(kind, s) + now = iso_now() + JOBS[jid] = { + 'status': status, + 'server': s.get('name'), + 'server_id': str(server_id(s)), + 'kind': kind, + 'created_at': now, + **extra, + } + if status == 'running': + JOBS[jid]['started_at'] = now + return jid + + +def start_job(s, kind, **extra): + key = (server_id(s), kind) + if key in RUNNING: + return None, key + RUNNING.add(key) + jid = create_job(s, kind, status='running', **extra) + return jid, key + + +def finish_job(jid, key=None): + job = JOBS.get(jid) or {} + now = iso_now() + job.setdefault('status', 'done') + job['completed_at'] = now + try: + st = datetime.fromisoformat(str(job.get('started_at') or job.get('created_at'))) + en = datetime.fromisoformat(now) + job['duration_sec'] = max(0, int((en - st).total_seconds())) + except Exception: + pass + JOBS[jid] = job + history_append(jid, job) + if key: + RUNNING.discard(key) + + +def launch_job(s, kind, runner, bot, chat_id, server, *runner_tail, **extra): + jid, _key = start_job(s, kind, **extra) + if not jid: + return None + asyncio.create_task(runner(bot, chat_id, server, jid, *runner_tail)) + return jid + + +def server_history(s, limit=20): + sid = str(server_id(s)) + name = str(s.get('name') or '') + host = str(s.get('host') or '') + out = [] + seen = set() + for item in load_history(): + if str(item.get('server_id') or '') == sid or str(item.get('server') or '') in (name, host, sid): + kind = item.get('kind') + if kind: + seen.add(kind) + out.append(item) + scan_kinds = [] + root = RESULTS_DIR / sid + if root.exists(): + scan_kinds.extend([p.name for p in root.iterdir() if p.is_dir()]) + scan_kinds.extend(['bgp']) + for kind in sorted(set(scan_kinds)): + if kind in seen: + continue + files = all_result_files(s, kind) + if not files: + continue + newest = files[0] + out.append({ + 'job_id': f'file-{sid}-{kind}', + 'server': s.get('name'), + 'server_id': sid, + 'kind': kind, + 'status': 'done', + 'completed_at': datetime.fromtimestamp(newest.stat().st_mtime).astimezone().isoformat(timespec='seconds'), + 'media_paths': [str(p) for p in files], + 'log_tail': str(newest), + }) + return out[-limit:] + + +def history_item_for(s, kind): + for item in reversed(server_history(s, 50)): + if item.get('kind') == kind: + return item + return None + + +def history_markup(s): + sid = server_id(s) + items = list(reversed(server_history(s, 50))) + buttons = [] + seen = set() + for item in items: + kind = item.get('kind') + if not kind or kind in seen: + continue + seen.add(kind) + icon = STATUS_ICON.get(item.get('status'), '•') + label = f'{icon} {KIND_NAME.get(kind, kind)}' + buttons.append(InlineKeyboardButton(label, callback_data=f'histd:{sid}:{kind}')) + rows = button_rows(buttons, 2) + rows.append([InlineKeyboardButton('🔄 刷新历史', callback_data=f'hist:{sid}')]) + rows.append([InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')]) + return InlineKeyboardMarkup(rows) + + +def history_detail_text(s, kind): + item = history_item_for(s, kind) + if not item: + return f'📜 {safe(s.get("name"))} 暂无 {safe(KIND_NAME.get(kind, kind))} 历史。' + icon = STATUS_ICON.get(item.get('status'), '•') + lines = [ + f'{icon} {safe(s.get("name"))} · {safe(KIND_NAME.get(kind, kind))}', + f'状态:{safe(item.get("status") or "-")}', + ] + when = item.get('completed_at') or item.get('started_at') + if when: + lines.append(f'时间:{safe(when)}') + if item.get('duration_sec') is not None: + lines.append(f'耗时:{safe(item.get("duration_sec"))}s') + params = [] + for label, key in [('目标', 'target'), ('选择', 'selected'), ('IP模式', 'ip_mode'), ('地区', 'region')]: + if item.get(key): + params.append(f'{label}:{item.get(key)}') + if params: + lines.append('参数:' + safe(';'.join(params))) + urls = item.get('urls') or [] + if urls: + lines.append('\n链接:\n' + '\n'.join(safe(u) for u in urls[:8])) + media = latest_media_path(item) + if media: + lines.append('\n图片:点击后会重新发送最近一次结果图。') + log_tail = (item.get('log_tail') or '').strip() + if log_tail and not media: + lines.append('\n详情:\n
' + safe(log_tail[-3200:]) + '
') + else: + lines.append('\n详情:暂无可展示内容。') + return '\n'.join(lines) + + +def history_text(s): + items = server_history(s, 20) + if not items: + return f'📜 {safe(s.get("name"))} 暂无测试历史。' + lines = [f'📜 {safe(s.get("name"))} 最近一次测试结果', '点下面的功能按钮可以查看具体内容。'] + for item in reversed(items): + icon = STATUS_ICON.get(item.get('status'), '•') + kind = KIND_NAME.get(item.get('kind'), item.get('kind') or '-') + extra = [] + for k in ('target', 'selected', 'ip_mode', 'region'): + if item.get(k): + extra.append(str(item.get(k))) + dur = f" · {item.get('duration_sec')}s" if item.get('duration_sec') is not None else '' + when = item.get('completed_at') or item.get('started_at') or '-' + suffix = f" — {';'.join(extra)}" if extra else '' + lines.append(f'{icon} {safe(kind)} · {safe(item.get("status"))}{safe(dur)}\n {safe(when)}{safe(suffix)}') + urls = item.get('urls') or [] + if urls: + lines.append(' ' + safe(urls[0])) + return '\n'.join(lines) + + +def server_jobs(s): + sid = str(server_id(s)) + name = str(s.get('name') or '') + host = str(s.get('host') or '') + found = [] + for jid, j in JOBS.items(): + if (j.get('status') or '') != 'running': + continue + j_server = str(j.get('server') or '') + if f'-{sid}-' in str(jid) or j_server in (name, host, sid): + found.append((jid, j)) + found.sort(key=lambda kv: kv[1].get('created_at') or kv[1].get('started_at') or kv[0]) + return found + + +def compact_job_line(s, jid, j): + status = j.get('status') or '-' + icon = STATUS_ICON.get(status, '•') + kind = KIND_NAME.get(j.get('kind'), j.get('kind') or '-') + parts = [kind] + if j.get('selected'): + parts.append(str(j.get('selected'))) + elif j.get('target'): + parts.append(str(j.get('target'))) + elif j.get('region'): + parts.append(str(j.get('region'))) + label = '·'.join(parts) + tail = status + extras = [] + if j.get('ip_mode'): + extras.append(str(j.get('ip_mode'))) + if extras: + tail += f"({';'.join(extras)})" + return f'{icon} {safe(label)} - {safe(tail)}' + + +def job_status_text(s): + jobs = server_jobs(s) + if not jobs: + return f'📋 {safe(s.get("name"))} 当前没有任务。' + lines = [f'📋 {safe(s.get("name"))} 当前任务'] + for jid, j in jobs[-10:]: + lines.append(compact_job_line(s, jid, j)) + return '\n'.join(lines) + + + +def extract_urls(text): + text = strip_ansi(text or '') + urls = re.findall(r'https?://[^\s<>"\'\x00-\x1f\x7f]+', text) + return [u.rstrip('.,;,。)】]') for u in urls] + + +def history_urls(text, limit=24): + urls = [] + seen = set() + nq = nodequality_url(text) + if nq: + urls.append(nq) + seen.add(nq) + for u in extract_urls(text): + clean = strip_ansi(u).rstrip('.,;,。)】]') + m = re.search(r'(https?://nodequality\.com/r/[A-Za-z0-9]{32})', clean) + if m: + clean = m.group(1) + if clean not in seen and ( + 'Report.Check.Place/' in clean + or 'browser.geekbench.com/' in clean + or 'nodequality.com/r/' in clean + ): + urls.append(clean) + seen.add(clean) + for u in extract_urls(text): + clean = strip_ansi(u).rstrip('.,;,。)】]') + if clean not in seen: + urls.append(clean) + seen.add(clean) + if len(urls) >= limit: + break + return urls[:limit] + + +def first_report_url(text, category=None): + urls = extract_urls(text) + reports = [u for u in urls if 'Report.Check.Place' in u] + if category: + needle = f'/Report.Check.Place/{category}/' + needle2 = f'Report.Check.Place/{category}/' + for u in reports: + clean = strip_ansi(u).strip() + if needle in clean or needle2 in clean: + return clean + return None + return strip_ansi(reports[0]).strip() if reports else None + + + +def geekbench_urls(text): + urls = [] + seen = set() + for u in extract_urls(text): + clean = strip_ansi(u).rstrip('.,;,。)】]') + if re.search(r'browser\.geekbench\.com/v\d+/cpu/\d+', clean) and clean not in seen: + urls.append(clean) + seen.add(clean) + return urls + +def nodequality_url(text): + clean = strip_ansi(text or '') + # NodeQuality output can be immediately followed by curl progress digits + # (for example the trailing "00" from "100"), so capture only the token. + m = re.search(r'https?://nodequality\.com/r/([A-Za-z0-9]{32})', clean) + if m: + return f'https://nodequality.com/r/{m.group(1)}' + for u in extract_urls(clean): + u = strip_ansi(u).rstrip('.,;,。') + m = re.search(r'(https?://nodequality\.com/r/[A-Za-z0-9]{32})', u) + if m: + return m.group(1) + return None + + +def all_report_urls(text, category): + urls = [] + seen = set() + needle = f'Report.Check.Place/{category}/' + for u in extract_urls(text): + clean = strip_ansi(u).strip() + if needle in clean and clean not in seen: + urls.append(clean) + seen.add(clean) + return urls + + +def trim_log(text, limit=3200): + text = (text or '').strip() + return text[-limit:] if len(text) > limit else text + + + +def run_pty_command_sync(args, timeout=900, send_enter_after=2): + master, slave = os.openpty() + try: + try: + os.set_blocking(master, False) + except Exception: + pass + proc = subprocess.Popen(args, stdin=slave, stdout=slave, stderr=slave, close_fds=True) + os.close(slave) + slave = None + out = bytearray() + start = time.monotonic() + sent_enter = False + while True: + now = time.monotonic() + if not sent_enter and now - start >= send_enter_after: + try: + os.write(master, b'\r') + except OSError: + pass + sent_enter = True + if now - start > timeout: + try: + proc.terminate() + time.sleep(1) + if proc.poll() is None: + proc.kill() + except Exception: + pass + return 124, out.decode(errors='replace') + '\n命令超时' + r, _, _ = select.select([master], [], [], 0.2) + if r: + try: + chunk = os.read(master, 8192) + if chunk: + out.extend(chunk) + except OSError: + pass + if proc.poll() is not None: + # drain remaining output + for _ in range(10): + r, _, _ = select.select([master], [], [], 0.05) + if not r: + break + try: + chunk = os.read(master, 8192) + if chunk: + out.extend(chunk) + except OSError: + break + return proc.returncode, out.decode(errors='replace') + finally: + if slave is not None: + try: + os.close(slave) + except OSError: + pass + try: + os.close(master) + except OSError: + pass + + +async def run_pty_command(args, timeout=900, send_enter_after=2): + return await asyncio.to_thread(run_pty_command_sync, args, timeout, send_enter_after) + + +async def send_long_text(bot, chat_id, text, *, parse_mode=None): + text = text or '无输出' + max_len = 3600 if parse_mode == ParseMode.HTML else 3900 + chunks = [text[i:i + max_len] for i in range(0, len(text), max_len)] or ['无输出'] + for chunk in chunks[:3]: + await bot.send_message(chat_id, chunk, parse_mode=parse_mode) + + + +async def run_until_report(args, timeout=900, env=None): + proc = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env + ) + out = bytearray() + start = time.monotonic() + report = None + try: + while True: + if time.monotonic() - start > timeout: + proc.kill() + await proc.wait() + return 124, out.decode(errors='replace') + '\n命令超时', report + try: + chunk = await asyncio.wait_for(proc.stdout.read(1024), timeout=1) + except asyncio.TimeoutError: + if proc.returncode is not None: + break + continue + if chunk: + out.extend(chunk) + text = out.decode(errors='replace') + report = first_report_url(text, 'ip') or report + if report: + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), timeout=3) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 0, text, report + elif proc.returncode is not None: + break + text = out.decode(errors='replace') + return proc.returncode, text, first_report_url(text, 'ip') + finally: + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + + +async def run_subprocess(args, timeout, *, send_enter_after=None, env=None): + proc = await asyncio.create_subprocess_exec( + *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, env=env + ) + async def nudge_enter(): + if send_enter_after is None: + return + await asyncio.sleep(send_enter_after) + if proc.returncode is None and proc.stdin: + try: + proc.stdin.write(b'\n') + await proc.stdin.drain() + except Exception: + pass + nudger = asyncio.create_task(nudge_enter()) + try: + out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + try: + out, _ = await proc.communicate() + except Exception: + out = b'' + return 124, (out or b'').decode(errors='replace') + '\n命令超时' + finally: + nudger.cancel() + return proc.returncode, out.decode(errors='replace') + + +async def resolve_target_to_ipv4(target): + ip = extract_ipv4(target) + if ip: + return ip, None + host = normalize_domain(target) + if not host: + raise RuntimeError('没有识别到 IPv4 或域名') + def lookup(): + infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM) + seen = [] + for info in infos: + addr = info[4][0] + if addr not in seen: + seen.append(addr) + if not seen: + raise RuntimeError('域名没有解析到 IPv4') + return seen[0] + return await asyncio.to_thread(lookup), host + + +def parse_bgp_png(stdout, target, outdir): + m = re.search(r'^LATEST=(.+)$', stdout or '', re.M) + if m: + return Path(m.group(1).strip()) + return Path(outdir) / f'latest-{safe_target(target)}.png' + + +def is_bgp_temporary_no_path(output): + text = str(output or '') + return ( + 'PLACEHOLDER' in text + or 'NONE' in text + or 'temporarily returned no path image' in text + or 'prefix not visible in DFZ' in text + or 'no usable BGP path image found' in text + or 'no path data' in text + ) + + +def bgp_retry_message(): + return 'BGP 图暂时没取到,应该是 bgp.tools 偶发抽风,请再试一次。' + + +async def ensure_bgp_tool(): + if BGP_FETCH.exists(): + return + bundled = Path('/app/tools/bgp_fetch.py') + if bundled.exists(): + BGP_FETCH.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bundled, BGP_FETCH) + os.chmod(BGP_FETCH, 0o755) + return + raise RuntimeError('BGP 工具不存在。请使用项目 Dockerfile 构建镜像,或设置 BGP_FETCH 指向 bgp_fetch.py。') + + +async def ensure_ippure_tool(): + if not shutil.which('node'): + raise RuntimeError('容器里没有 node,无法运行 IPPure。请使用项目 Dockerfile 构建镜像。') + if not IPPURE_DOWNLOAD.exists(): + bundled = Path('/app/tools/download_ippure.js') + if bundled.exists(): + IPPURE_DOWNLOAD.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bundled, IPPURE_DOWNLOAD) + os.chmod(IPPURE_DOWNLOAD, 0o755) + else: + raise RuntimeError('IPPure 工具不存在。请使用项目 Dockerfile 构建镜像,或设置 IPPURE_DOWNLOAD 指向 download_ippure.js。') + try: + code, _ = await run_subprocess([ + 'node', '-e', + 'const fs=require("fs"); const paths=[process.env.CHROMIUM_PATH,"/usr/bin/chromium","/usr/bin/chromium-browser","/usr/bin/google-chrome","/usr/bin/google-chrome-stable"].filter(Boolean); ' + 'if(paths.some(p=>fs.existsSync(p))){process.exit(0)}; ' + 'const {chromium}=require("playwright"); const p=chromium.executablePath(); console.log(p); process.exit(fs.existsSync(p)?0:2)' + ], timeout=20) + if code != 0: + raise RuntimeError('missing playwright browser') + except Exception: + code, out = await run_subprocess(['bash', '-lc', 'npm install -g playwright@1.59.1 && PLAYWRIGHT_BROWSERS_PATH=${PLAYWRIGHT_BROWSERS_PATH:-/ms-playwright} npx playwright install chromium chromium-headless-shell'], timeout=600) + if code != 0: + raise RuntimeError('Playwright 自动安装失败:\n' + trim_log(out, 1000)) + + +async def generate_bgp_png(ip): + await ensure_bgp_tool() + outdir = BGP_OUT_ROOT / f'bgp-{int(time.time())}-{os.getpid()}' + outdir.mkdir(parents=True, exist_ok=True) + code, out = await run_subprocess(['python3', str(BGP_FETCH), '--outdir', str(outdir), ip], timeout=120) + if code != 0: + if is_bgp_temporary_no_path(out): + raise RuntimeError(bgp_retry_message()) + raise RuntimeError(trim_log(out, 1000) or f'BGP 生成失败:{code}') + png = parse_bgp_png(out, ip, outdir) + if not png.exists() or png.stat().st_size <= 0: + raise RuntimeError('BGP 图片生成后未找到文件') + return png + + +async def generate_ippure_png(ip): + await ensure_ippure_tool() + outdir = IPPURE_TMP_ROOT / f'ippure-{int(time.time())}-{os.getpid()}' + outdir.mkdir(parents=True, exist_ok=True) + code, out = await run_subprocess(['node', str(IPPURE_DOWNLOAD), '--ip', ip, '--outdir', str(outdir)], timeout=120) + if code != 0: + raise RuntimeError(trim_log(out, 1000) or f'IPPure 生成失败:{code}') + candidates = [Path(line.strip()) for line in (out or '').splitlines() if line.strip().endswith('.png')] + png = candidates[-1] if candidates else None + if not png or not png.exists() or png.stat().st_size <= 0: + raise RuntimeError('IPPure 图片生成后未找到文件') + return png + + +async def send_png_and_cleanup(bot, chat_id, png, cleanup_dir=None): + with Path(png).open('rb') as f: + await bot.send_photo(chat_id, photo=f) + if cleanup_dir: + await asyncio.to_thread(shutil.rmtree, str(cleanup_dir), True) + + +async def run_bgp_task(bot, chat_id, s, jid): + key = (server_id(s), 'bgp') + try: + ip = s.get('host') + png = await generate_bgp_png(ip) + saved = persist_result_file(s, 'bgp', png, '.png') + JOBS[jid].update({'status': 'done', 'log': str(png), 'media_path': saved}) + await send_png_and_cleanup(bot, chat_id, png) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e)}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} BGP 图失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +async def run_ippure_task(bot, chat_id, s, jid): + key = (server_id(s), 'ippure') + try: + ip = s.get('host') + png = await generate_ippure_png(ip) + saved = persist_result_file(s, 'ippure', png, '.png') + JOBS[jid].update({'status': 'done', 'log': str(png), 'media_path': saved}) + await send_png_and_cleanup(bot, chat_id, png, Path(png).parent) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e)}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} IPPure 图失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +def ip_tools_markup(ip): + row = [] + if tool_enabled('ippure'): + row.append(InlineKeyboardButton('🧼 IPPure 图', callback_data=f'ippureip:{ip}')) + if tool_enabled('bgp'): + row.append(InlineKeyboardButton('🧭 BGP 图', callback_data=f'bgpip:{ip}')) + if not row: + row.append(InlineKeyboardButton('未启用 IP 图像工具', callback_data='noop')) + return InlineKeyboardMarkup([row]) + + +async def ensure_checkplace_renderer(): + if RENDER_CHECKPLACE.exists(): + return + fallback = Path(__file__).resolve().parent / 'render_checkplace.py' + if fallback.exists(): + return + raise RuntimeError('Check.Place PNG 渲染器不存在。请确认镜像包含 /app/render_checkplace.py,或设置 RENDER_CHECKPLACE。') + + +async def render_checkplace_png(svg_url, out_png): + await ensure_checkplace_renderer() + renderer = RENDER_CHECKPLACE if RENDER_CHECKPLACE.exists() else Path(__file__).resolve().parent / 'render_checkplace.py' + with tempfile.TemporaryDirectory() as td: + svg_path = Path(td) / 'report.svg' + def download(): + req = urllib.request.Request( + svg_url, + headers={ + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36', + 'Accept': 'image/svg+xml,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Referer': 'https://Report.Check.Place/', + }, + ) + with urllib.request.urlopen(req, timeout=60) as r: + svg_path.write_bytes(r.read()) + await asyncio.to_thread(download) + code, out = await run_subprocess(['python', str(renderer), str(svg_path), str(out_png)], timeout=90) + if code != 0: + raise RuntimeError(out[-1000:]) + + +async def send_report_images(bot, chat_id, report_links, prefix): + if not report_links: + return [] + out_dir = Path('/tmp/guko-results') + out_dir.mkdir(parents=True, exist_ok=True) + sent = [] + for label, url in report_links: + png = out_dir / f"{prefix}-{label}-{int(time.time())}.png" + await render_checkplace_png(url, png) + with png.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + sent.append((label, url, png)) + return sent + + +def proxy_tool_config(kind): + tool = PROXY_TOOLS.get(kind) + if not tool: + raise RuntimeError('未知工具') + return tool + + +def proxy_menu_text(s, kind): + tool = proxy_tool_config(kind) + return ( + f"{safe(tool['button'])} {safe(s.get('name'))} · {safe(tool['name'])}\n\n" + '安装/更新会检测目标服务器的协议核心/服务端,不是更新 GUKO 脚本本身。\n' + '查看只读取目标服务器当前配置并返回连接信息。' + ) + + +def proxy_markup(s, kind): + sid = server_id(s) + tool = proxy_tool_config(kind) + if kind == 'vless': + return InlineKeyboardMarkup([ + [InlineKeyboardButton('安装/更新 纯 VLESS', callback_data=f'vlessmode:plain:{sid}')], + [InlineKeyboardButton('安装/更新 Vision + Reality', callback_data=f'vlessmode:reality:{sid}')], + [InlineKeyboardButton('查看配置', callback_data=f'proxyrun:{kind}:view:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]) + return InlineKeyboardMarkup([ + [InlineKeyboardButton(f"安装/更新 {tool['name']} 服务端", callback_data=f'proxyrun:{kind}:ensure:{sid}')], + [InlineKeyboardButton('查看配置', callback_data=f'proxyrun:{kind}:view:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]) + + +def proxy_answers(kind, port=None, mode=None): + # Manager scripts are interactive for install: port/SNI/password prompts. + # Empty password keeps the script default random password. + port = str(port or '').strip() + if kind == 'ss': + # ss-rust also asks encryption method before password; empty method keeps the recommended default. + return f"{port}\n\n\n" + if kind == 'anytls': + return f"{port}\n\n" + if kind == 'snell': + return f"{port}\n\n" + if kind == 'vless': + # Xray-VLESS-Manager modes used by GUKO: + # 2 = pure VLESS TCP, 3 = VLESS + XTLS Vision + REALITY. + if mode == 'reality': + return f"{port}\n\n" + return f"{port}\n" + return "\n" + + +def proxy_extract_config(text, kind): + clean = strip_ansi(text or '').replace('\r', '') + if kind == 'vless': + lines = [] + keep = False + for raw in clean.splitlines(): + line = raw.rstrip() + if line.startswith('VLESS ') or line.startswith('vless://') or line.startswith('UUID:') or line.startswith('端口:') or line.startswith('客户端 JSON:') or line.startswith('服务端配置:') or line.startswith('{'): + keep = True + if keep: + if 'Xray VLESS Manager' in line or line.startswith('=== 基础功能 ===') or line.startswith('=== 服务管理 ===') or line.startswith('=== 系统功能 ===') or re.match(r'^\s*\d+\)', line) or line.strip() == '0) 退出': + break + lines.append(line) + body = '\n'.join(lines).strip() + return trim_log(body or clean.strip(), 3600) + + markers = ['当前配置', 'Surge:', 'Mihomo:', 'URI:', '地址:', '端口:', '密码:', '加密:', '版本:', 'UUID:', 'PublicKey:', 'ShortId:', 'SNI:', '服务端配置:', '客户端 JSON:'] + lines = [] + keep = False + for raw in clean.splitlines(): + line = raw.rstrip() + if any(m in line for m in markers): + keep = True + if keep: + lines.append(line) + body = '\n'.join(lines).strip() or clean.strip() + return trim_log(body, 3600) + + +def is_proxy_link_line(line): + s = (line or '').strip() + return s.startswith(('vless://', 'ss://', 'anytls://', 'snell://', 'VPS = ', '- {', '- {"', '{')) + + +def format_proxy_config_html(body): + if not body: + return '无节点配置输出' + out = [] + block = [] + + def flush_block(): + if block: + out.append('
' + safe('\n'.join(block)) + '
') + block.clear() + + for raw in str(body).splitlines(): + line = raw.rstrip() + if is_proxy_link_line(line): + block.append(line.strip()) + else: + flush_block() + out.append(safe(line)) + flush_block() + return '\n'.join(out).strip() + + +async def run_proxy_tool_task(bot, chat_id, s, jid, kind, action, mode=None): + key = (server_id(s), kind) + tool = proxy_tool_config(kind) + try: + script = tool['script_url'] + if action in ('install', 'ensure'): + port = os.environ.get(f"GUKO_{kind.upper()}_DEFAULT_PORT", '').strip() + dynamic_vless_port = kind == 'vless' and not port + if kind == 'vless' and not port: + port = '8443' + answers = proxy_answers(kind, port, mode) + service = tool['service'] + bin_path_map = { + 'ss': '/usr/local/bin/ss-rust', + 'anytls': '/usr/local/bin/anytls-server', + 'vless': '/usr/local/bin/xray', + 'snell': '/usr/local/bin/snell-server', + } + repo_map = { + 'ss': 'shadowsocks/shadowsocks-rust', + 'anytls': 'anytls/anytls-go', + 'vless': '', + 'snell': '', + } + bin_path = bin_path_map[kind] + repo = repo_map[kind] + if kind == 'ss': + version_cmd = '$BIN --version 2>/dev/null | head -n1 || true' + elif kind == 'anytls': + version_cmd = 'strings $BIN 2>/dev/null | grep -Eo "v?[0-9]+\\.[0-9]+\\.[0-9]+" | sort -Vr | head -n1 || true' + elif kind == 'vless': + version_cmd = '$BIN version 2>/dev/null | head -n1 || $BIN run -version 2>/dev/null | head -n1 || true' + else: + version_cmd = '$BIN version 2>/dev/null | head -n1 || $BIN --version 2>/dev/null | head -n1 || true' + latest_probe = '' + if kind in ('ss', 'anytls'): + latest_probe = 'latest=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep -m1 "tag_name" | sed -E "s/.*\\\"tag_name\\\"[[:space:]]*:[[:space:]]*\\\"([^\\\"]+)\\\".*/\\1/" || true); ' + elif kind == 'vless': + latest_probe = 'latest="official XTLS installer"; ' + else: + latest_probe = 'latest="latest from manager script"; ' + install_cmd = f'printf %b {shlex.quote(answers)} | bash "$tmp" install' + if kind == 'vless': + menu_choice = '3' if mode == 'reality' else '2' + safe_sed = ( + "perl -0pi -e 's/\\$XRAY_BIN test -config \"\\$CONFIG\"/if \\$XRAY_BIN help 2>\\/dev\\/null | grep -qE \"^[[:space:]]*test[[:space:]]\"; then \\$XRAY_BIN test -config \"\\$CONFIG\"; else \\$XRAY_BIN run -test -config \"\\$CONFIG\"; fi/' \"$tmp\"; " + "perl -0pi -e 's/\\\"geoip:private\\\"/\\\"127.0.0.0\\\\\\/8\\\",\\\"10.0.0.0\\\\\\/8\\\",\\\"172.16.0.0\\\\\\/12\\\",\\\"192.168.0.0\\\\\\/16\\\",\\\"fc00::\\\\\\/7\\\"/g' \"$tmp\"; " + ) + if dynamic_vless_port: + install_cmd = f"{safe_sed}guko_port=8443; if ss -lnt | awk 'NR>1 {{print $4}}' | grep -Eq '(^|:)8443$'; then while :; do guko_port=$(shuf -i 20000-65000 -n 1); ss -lnt | awk 'NR>1 {{print $4}}' | grep -Eq \"(^|:)${{guko_port}}$\" || break; done; echo \"GUKO_STATUS:默认端口 8443 已占用,改用 $guko_port\"; fi; printf \"%b\" \"{menu_choice}\\n${{guko_port}}\\n\\n0\\n\" | bash \"$tmp\"" + else: + vless_answers = menu_choice + '\n' + answers + '\n0\n' + install_cmd = f'{safe_sed}printf %b {shlex.quote(vless_answers)} | bash "$tmp"' + remote = ( + 'export TERM=xterm-256color; cd /root; ' + f'BIN={shlex.quote(bin_path)}; SERVICE={shlex.quote(service)}; REPO={shlex.quote(repo)}; ' + f'tmp=$(mktemp /root/guko-{kind}.XXXXXX.sh); ' + f'curl -LfsS {shlex.quote(script)} -o "$tmp"; chmod +x "$tmp"; ' + f'{latest_probe}' + f'current=""; [[ -x "$BIN" ]] && current=$({version_cmd}); ' + f'latest_num=${{latest#v}}; current_num=$(printf %s "$current" | grep -Eo "[0-9]+\\.[0-9]+\\.[0-9]+" | head -n1 || true); ' + f'if [[ ! -x "$BIN" || ! -f "/etc/systemd/system/$SERVICE.service" ]]; then ' + f' echo "GUKO_STATUS:未安装,开始安装"; ' + f' {install_cmd}; ' + f"elif [[ \"{kind}\" == \"vless\" && -s /usr/local/etc/xray/client.txt && -s /usr/local/etc/xray/config.json ]] && jq -e '.inbounds and (.inbounds|length>0) and any(.inbounds[]; .protocol == \"vless\")' /usr/local/etc/xray/config.json >/dev/null 2>&1; then " + f' echo "GUKO_STATUS:已安装且已有配置,无需重新安装"; ' + f' if command -v systemctl >/dev/null 2>&1 && ! systemctl is-active --quiet "$SERVICE"; then echo "GUKO_STATUS:服务未运行,尝试启动"; systemctl start "$SERVICE" || true; fi; ' + f' cat /usr/local/etc/xray/client.txt; ' + f"elif [[ \"{kind}\" == \"vless\" && -s /usr/local/etc/xray/config.json ]] && jq -e '.inbounds and (.inbounds|length>0) and all(.inbounds[]; .protocol != \"vless\")' /usr/local/etc/xray/config.json >/dev/null 2>&1; then " + f' echo "GUKO_STATUS:检测到现有 Xray 配置不是 VLESS,为避免覆盖请先迁移/备份现有配置或手动安装"; exit 23; ' + f'elif [[ "{kind}" != "ss" && "{kind}" != "anytls" ]]; then ' + f' echo "GUKO_STATUS:开始安装/更新协议服务端"; ' + f' {install_cmd}; ' + f'elif [[ -n "$latest_num" && -n "$current_num" && "$current_num" == "$latest_num" ]]; then ' + f' echo "GUKO_STATUS:已安装最新版,无需更新 ($latest)"; ' + f' if command -v systemctl >/dev/null 2>&1 && ! systemctl is-active --quiet "$SERVICE"; then echo "GUKO_STATUS:服务未运行,尝试启动"; systemctl start "$SERVICE" || true; fi; ' + f' bash "$tmp" view; ' + f'else ' + f' echo "GUKO_STATUS:发现程序更新:当前=${{current:-unknown}} 最新=${{latest:-unknown}},开始更新"; ' + f' {install_cmd}; ' + f'fi 2>&1' + ) + timeout = 1800 + elif action == 'view': + if kind == 'vless': + remote = ( + 'export TERM=xterm-256color; ' + 'if [[ -s /usr/local/etc/xray/client.txt ]]; then ' + ' cat /usr/local/etc/xray/client.txt; ' + 'elif [[ -s /usr/local/etc/xray/config.json ]]; then ' + ' echo "服务端配置: /usr/local/etc/xray/config.json"; ' + ' cat /usr/local/etc/xray/config.json; ' + 'else ' + ' echo "暂无配置"; ' + 'fi 2>&1' + ) + else: + remote = ( + 'export TERM=xterm-256color; cd /root; ' + f'tmp=$(mktemp /root/guko-{kind}.XXXXXX.sh); ' + f'curl -LfsS {shlex.quote(script)} -o "$tmp"; chmod +x "$tmp"; ' + 'bash "$tmp" view 2>&1' + ) + timeout = 300 + else: + raise RuntimeError('未知操作') + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=timeout, env=ssh_env_for(s)) + sections = proxy_extract_config(out, kind) + ok = code == 0 and bool(sections) + JOBS[jid].update({'status': 'done' if ok else 'failed', 'log': out, 'target': action}) + if action in ('install', 'ensure'): + if 'GUKO_STATUS:未安装' in out: + title = '安装完成' + elif 'GUKO_STATUS:已安装最新版' in out: + title = '已安装最新版,无需更新' + elif 'GUKO_STATUS:发现程序更新' in out: + title = '程序已更新' + else: + title = '安装/更新检查完成' + else: + title = '当前配置' + icon = '✅' if ok else '❌' + msg = f"{icon} {safe(s.get('name'))} {safe(tool['name'])} {title}" + if not ok: + msg += f"(退出码 {safe(code)})" + status_lines = [line.split(':', 1)[1] for line in strip_ansi(out or '').splitlines() if line.startswith('GUKO_STATUS:')] + status_text = '\n'.join(status_lines).strip() + if status_text: + msg += f"\n{safe(status_text)}" + msg += f"\n\n{format_proxy_config_html(sections)}" + await send_long_text(bot, chat_id, msg, parse_mode=ParseMode.HTML) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e), 'target': action}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} {safe(tool['name'])} 任务失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +async def run_ip_quality_task(bot, chat_id, s, jid): + key = (server_id(s), 'ipq') + try: + remote = "export TERM=xterm-256color; cd /tmp && bash <(curl -Ls https://IP.Check.Place) -y" + code, out, url = await run_until_report(ssh_args(s, remote, tty=False), timeout=900, env=ssh_env_for(s)) + JOBS[jid].update({'status': 'done' if code == 0 else 'failed', 'log': out}) + if not url: + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} IP质量没拿到报告链接。\n
{safe(trim_log(out))}
", parse_mode=ParseMode.HTML) + return + out_dir = Path('/tmp/guko-results') + out_dir.mkdir(parents=True, exist_ok=True) + png = out_dir / f"ipq-{server_id(s)}-{int(time.time())}.png" + try: + await render_checkplace_png(url, png) + saved = persist_result_file(s, 'ipq', png, '.png') + JOBS[jid].update({'media_path': saved}) + with png.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + await bot.send_message(chat_id, f"✅ {safe(s.get('name'))} IP质量完成\n{safe(url)}\n\n{script_command_html('ipq')}", parse_mode=ParseMode.HTML) + except Exception as e: + await bot.send_message(chat_id, f"✅ {safe(s.get('name'))} IP质量报告:\n{safe(url)}\n\n{script_command_html('ipq')}\n\n转 PNG 失败:{safe(e)}", parse_mode=ParseMode.HTML) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e)}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} IP质量任务失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +async def fetch_checkplace_svg_from_json(category, json_path): + # Check.Place rejects replayed/masked JSON from nodequality.com exports, so this + # is only a best-effort fallback for future unmasked JSON cases. Normal path + # should parse the SVG printed by the live sub-script stdout. + if not json_path.exists() or json_path.stat().st_size <= 0: + return None + script = ( + "json_file=$1; category=$2; " + "curl -s -X POST https://upload.check.place " + "-d type=$category --data-urlencode json@$json_file --data-urlencode content=" + ) + code, text = await run_subprocess(['bash', '-lc', script, 'bash', str(json_path), category], timeout=60) + if code != 0: + return None + m = re.search(r'https://Report\.Check\.Place/[^\s<>"]+\.svg', text) + return m.group(0) if m else None + + +def report_log_from_nodequality_url(text): + token = None + nq = nodequality_url(text) + if nq: + token = nq.rstrip('/').split('/')[-1] + return f"https://api.nodequality.com/api/v1/record/{token}" if token else None + + +def nodequality_token(text): + nq = nodequality_url(text) + return nq.rstrip('/').split('/')[-1] if nq else None + + +async def upload_nodequality_result_from_remote(s): + """Re-upload exactly like official NodeQuality.sh: base64(result.zip) as raw POST body.""" + remote = r'''set -e +z="" +for d in $(ls -td /root/.nodequality* /tmp/.nodequality* 2>/dev/null); do + if [ -s "$d/result.zip" ]; then z="$d/result.zip"; break; fi +done +[ -n "$z" ] || exit 2 +# Official NodeQuality.sh does: base64 result.zip | curl --data-binary @- +base64 "$z" | curl -fsS -X POST --data-binary @- https://api.nodequality.com/api/v1/record +''' + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=120, env=ssh_env_for(s)) + if code != 0: + return None + return nodequality_url(out) + + +async def recover_report_links_from_remote(s, selected): + cats = [] + for label, cat, bit in [('硬件', 'hardware', 1), ('IP质量', 'ip', 2), ('网络', 'net', 4), ('回程', 'backroute', 8)]: + if selected & bit: + cats.append((label, cat)) + if not cats: + return [] + remote = """td=$(mktemp -d /tmp/nqrecover.XXXXXX) +for d in $(ls -td /root/.nodequality* /tmp/.nodequality* 2>/dev/null); do + r=\"$d/BenchOs/result\" + [ -d \"$r\" ] || { [ -s \"$d/result.zip\" ] && unzip -oq \"$d/result.zip\" -d \"$td\" && r=\"$td\" || continue; } + for pair in hardware:hardware_quality.json ip:ip_quality.json net:net_quality.json backroute:backroute_trace.json; do + cat=${pair%%:*}; fn=${pair#*:}; p=\"$r/$fn\" + [ -s \"$p\" ] && printf '%s\t%s\n' \"$cat\" \"$p\" + done +done || true""" + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=30, env=ssh_env_for(s)) + if code != 0: + return [] + mapping = {} + for line in out.splitlines(): + if '\t' not in line: + continue + cat, path = line.split('\t', 1) + mapping.setdefault(cat, path) + recovered = [] + with tempfile.TemporaryDirectory() as td: + for label, cat in cats: + rp = mapping.get(cat) + if not rp: + continue + local = Path(td) / f'{cat}.json' + scp_args = scp_from_args(s, rp, str(local), inv=load_inventory()) + c, scp_out = await run_subprocess(scp_args, timeout=60, env=ssh_env_for(s)) + if c != 0: + c, data = await run_subprocess(ssh_args(s, f"cat {shlex.quote(rp)}", tty=False), timeout=60, env=ssh_env_for(s)) + if c != 0: + continue + local.write_text(data) + try: + u = await fetch_checkplace_svg_from_json(cat, local) + except Exception: + u = None + if u: + recovered.append((label, u)) + return recovered + + +def parse_geekbench5_scores(text): + clean = strip_ansi(text or '') + scores = {} + patterns = { + 'single': r'Single-Core Score\s+(\d+)', + 'multi': r'Multi-Core Score\s+(\d+)', + 'url': r'https://browser\.geekbench\.com/v5/cpu/\d+', + } + for key, pat in patterns.items(): + m = re.search(pat, clean, re.I) + if m: + scores[key] = m.group(1) if key != 'url' else m.group(0) + return scores + + +def gb5_result_image(s, scores, out_png): + out_png = Path(out_png) + out_png.parent.mkdir(parents=True, exist_ok=True) + W, H = 1080, 1350 + bg = (245, 247, 250) + blue = (47, 111, 191) + dark = (30, 41, 59) + text = (31, 41, 55) + muted = (100, 116, 139) + line = (226, 232, 240) + green = (22, 163, 74) + try: + font_title = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 52) + font_h1 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 42) + font_h2 = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 32) + font_score = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 86) + font_txt = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 28) + font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 22) + except Exception: + font_title = font_h1 = font_h2 = font_score = font_txt = font_small = ImageFont.load_default() + + def fit(draw, value, font, width): + value = str(value or '-') + if draw.textlength(value, font=font) <= width: + return value + ell = '…' + while value and draw.textlength(value + ell, font=font) > width: + value = value[:-1] + return value + ell + + im = Image.new('RGB', (W, H), bg) + d = ImageDraw.Draw(im) + d.rectangle([0, 0, W, 110], fill=(255, 255, 255)) + d.text((70, 32), 'Geekbench Browser', fill=blue, font=font_title) + d.rounded_rectangle([70, 150, W-70, 425], radius=18, fill=(255, 255, 255), outline=line, width=2) + d.text((110, 190), 'Geekbench 5 Score', fill=muted, font=font_txt) + single = str(scores.get('single') or '-') + multi = str(scores.get('multi') or '-') + d.text((145, 255), single, fill=dark, font=font_score) + d.text((145, 350), 'Single-Core Score', fill=muted, font=font_txt) + d.line([W//2, 220, W//2, 390], fill=line, width=2) + d.text((620, 255), multi, fill=dark, font=font_score) + d.text((620, 350), 'Multi-Core Score', fill=muted, font=font_txt) + + d.rounded_rectangle([70, 465, W-70, 820], radius=18, fill=(255, 255, 255), outline=line, width=2) + d.text((110, 505), str(s.get('name') or s.get('host') or 'Server'), fill=text, font=font_h1) + rows = [ + ('Operating System', f"{s.get('platform') or '-'} {s.get('platform_version') or ''}".strip()), + ('Model', str(s.get('name') or '-')), + ('Processor', str(s.get('cpu') or '-')), + ('Memory', fmt_bytes(s.get('mem_total'))), + ('IPv4', str(s.get('host') or '-')), + ] + y = 585 + for k, v in rows: + d.text((110, y), k, fill=muted, font=font_small) + d.text((390, y), fit(d, v, font_small, 560), fill=text, font=font_small) + y += 42 + + d.rounded_rectangle([70, 860, W-70, 1180], radius=18, fill=(255, 255, 255), outline=line, width=2) + d.text((110, 900), 'Benchmark Summary', fill=text, font=font_h2) + d.text((110, 965), 'Single-Core', fill=muted, font=font_txt) + d.rounded_rectangle([330, 970, 900, 1000], radius=15, fill=(219, 234, 254)) + try: + sw = max(8, min(570, int(single) / max(int(multi or 1), int(single), 1) * 570)) + except Exception: + sw = 20 + d.rounded_rectangle([330, 970, 330 + sw, 1000], radius=15, fill=blue) + d.text((920, 960), single, fill=text, font=font_txt, anchor='ra') + d.text((110, 1045), 'Multi-Core', fill=muted, font=font_txt) + d.rounded_rectangle([330, 1050, 900, 1080], radius=15, fill=(220, 252, 231)) + d.rounded_rectangle([330, 1050, 900, 1080], radius=15, fill=green) + d.text((920, 1040), multi, fill=text, font=font_txt, anchor='ra') + if scores.get('url'): + d.text((110, 1125), fit(d, scores['url'], font_small, 850), fill=blue, font=font_small) + + d.text((70, 1255), 'Generated by GUKO · Geekbench 5', fill=muted, font=font_small) + im.save(out_png, quality=95) + return out_png + +async def run_gb5_task(bot, chat_id, s, jid): + key = (server_id(s), 'gb5') + try: + remote = ( + "set -e; export TERM=xterm-256color; cd /root; " + "swapfile=; cleanup(){ if [ -n \"$swapfile\" ]; then swapoff $swapfile 2>/dev/null || true; rm -f $swapfile; fi; }; trap cleanup EXIT; " + "mem_kb=$(awk '/MemTotal:/ {print $2}' /proc/meminfo); " + "if [ ${mem_kb:-0} -lt 900000 ]; then " + "swapfile=/root/geekbench5.swap; rm -f $swapfile; " + "(fallocate -l 2G $swapfile 2>/dev/null || dd if=/dev/zero of=$swapfile bs=1M count=2048 status=none); " + "chmod 600 $swapfile; mkswap $swapfile >/dev/null; swapon $swapfile; " + "fi; " + "d=/root/Geekbench-" + GB5_VERSION + "-Linux; " + "if [ ! -x $d/geekbench5 ]; then " + "curl -fsSL " + shlex.quote(GB5_URL) + " -o /tmp/geekbench5.tar.gz; " + "tar -xzf /tmp/geekbench5.tar.gz -C /root; " + "fi; " + "$d/geekbench5 --upload 2>&1" + ) + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=3600, env=ssh_env_for(s)) + scores = parse_geekbench5_scores(out) + gb_urls = [u for u in extract_urls(out) if 'browser.geekbench.com/v5/cpu/' in u] + if gb_urls: + scores['url'] = gb_urls[-1] + JOBS[jid].update({'status': 'done' if code == 0 and scores.get('url') else 'failed', 'log': out}) + if code != 0 or not scores.get('url'): + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} GB5 没拿到结果链接。\n
{safe(trim_log(out))}
", parse_mode=ParseMode.HTML) + return + img = gb5_result_image(s, scores, RESULTS_DIR / str(server_id(s)) / 'gb5' / 'latest.jpg') + JOBS[jid].update({'media_path': str(img)}) + with img.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + await bot.send_message(chat_id, f"✅ {safe(s.get('name'))} GB5 完成\n{safe(scores['url'])}", parse_mode=ParseMode.HTML, disable_web_page_preview=True) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e)}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} GB5任务失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + +def stream_clean_output(text): + clean = strip_ansi(text or '') + clean = clean.replace('\r', '\n') + lines = [] + for raw in clean.splitlines(): + line = raw.strip() + if not line: + continue + if set(line) <= set('-_=* '): + continue + skip_needles = [ + '请选择检测项目', '请输入正确数字', '检测脚本当天运行次数', '本次测试已结束', + '感谢使用此脚本', '广告招租', '请联系', 'Github', 'YouTube', '支持系统', + 'RegionRestrictionCheck', 'Streaming Media Unlock Test', '正在下载', 'Downloading', + 'Number of Script Runs', 'Testing Done', 'Press ENTER', 'Input Number', '请输入', + ] + if any(x.lower() in line.lower() for x in skip_needles): + continue + lines.append(line) + return lines + + +def parse_stream_results(text): + lines = stream_clean_output(text) + net_type = '' + section = '' + results = [] + network = [] + for line in lines: + l = line.strip() + if '正在测试 IPv4' in l or 'Checking Results Under IPv4' in l: + net_type = 'IPv4' + continue + if '正在测试 IPv6' in l or 'Checking Results Under IPv6' in l: + net_type = 'IPv6' + continue + if '正在测试默认网络' in l or 'Checking Results Under Default' in l: + net_type = '默认网络' + continue + if '您的网络为:' in l or 'Your Network Provider:' in l: + network.append(l.replace('**', '').strip()) + continue + # Original script section lines: + # ============[ Multination ]============ + # ---Game--- + msec = re.search(r'\[\s*([^\]]+?)\s*\]', l) + if msec and set(l.replace(msec.group(0), '')) <= set('=-_ '): + section = msec.group(1).strip() + continue + msub = re.match(r'^-+\s*([^\-]+?)\s*-+$', l) + if msub: + section = msub.group(1).strip() + continue + if set(l) <= set('= '): + continue + m = re.match(r'(.+?):\s*(Yes|No|Failed|Originals Only|IPv6 Is Not Currently Supported|Available For .* Soon|即将推出|Unsupported|N/A)(.*)$', l, re.I) + if not m: + continue + name = m.group(1).strip() + status = m.group(2).strip() + extra = m.group(3).strip() + sec = section or net_type or '-' + results.append({'section': sec, 'net': net_type, 'name': name, 'status': status, 'extra': extra}) + return network, results + + +def stream_status_icon(status, extra=''): + status_l = str(status or '').lower() + t = f'{status} {extra}'.lower() + if 'only available' in t or 'only avaliable' in t or 'mobile app' in t: + return '🟡' + if status_l == 'yes' or 'region:' in t or 'available for' in t: + return '✅' + if status_l == 'no' or 'not available' in t or 'blocked' in t: + return '❌' + if 'originals only' in t: + return '🟡' + if 'ipv6 is not currently supported' in t or 'unsupported' in t: + return '➖' + return '⚠️' + + +def format_stream_summary(s, out, proto, region_label, region_id): + network, results = parse_stream_results(out) + groups = OrderedDict() + for r in results: + groups.setdefault(r['section'], []).append(r) + total = len(results) + yes = sum(1 for r in results if stream_status_icon(r['status'], r.get('extra')) == '✅') + no = sum(1 for r in results if stream_status_icon(r['status'], r.get('extra')) == '❌') + warn = max(0, total - yes - no) + head = [ + f'🎬 {safe(s.get("name"))} 流媒体检测完成', + f'协议:{safe(proto)} · 地区:{safe(region_label)}', + f'检测时间:{safe(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))}', + ] + if network: + head.append(safe(network[-1])) + if total: + head.append(f'结果:Yes {yes} / No {no} / Error {warn}') + parts = ['\n'.join(head)] + if not results: + parts.append('没解析到结构化结果,末尾日志:\n
' + safe(trim_log(strip_ansi(out), 2600)) + '
') + return '\n\n'.join(parts) + for sec, items in groups.items(): + lines = [f'{safe(sec)}'] + for r in items[:80]: + extra = (' ' + r.get('extra', '')) if r.get('extra') else '' + lines.append(f'{safe(r["name"])}:{safe(r["status"] + extra)}') + parts.append('\n'.join(lines)) + text = '\n\n'.join(parts) + return text[:3900] + ('\n\n…结果较长,已截断。' if len(text) > 3900 else '') + + +def stream_status_color(status, extra=''): + icon = stream_status_icon(status, extra) + if icon == '✅': + return (22, 163, 74) + if icon == '❌': + return (220, 38, 38) + return (202, 138, 4) + + +def stream_status_label(status, extra=''): + text = (str(status or '') + (' ' + str(extra).strip() if extra else '')).strip() + if not text: + return 'Error' + if text.lower().startswith('failed'): + return re.sub(r'^Failed', 'Error', text, flags=re.I) + return text + + +def load_font(candidates, size): + for path in candidates: + try: + return ImageFont.truetype(path, size) + except Exception: + pass + return ImageFont.load_default() + + +def stream_result_image(s, out, proto, region_label, region_id, out_png): + network, results = parse_stream_results(out) + if not results: + return None + groups = OrderedDict() + for r in results: + groups.setdefault(r['section'], []).append(r) + total = len(results) + yes = sum(1 for r in results if stream_status_icon(r['status'], r.get('extra')) == '✅') + no = sum(1 for r in results if stream_status_icon(r['status'], r.get('extra')) == '❌') + warn = max(0, total - yes - no) + + font_cjk = [ + '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', + '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + ] + font_cjk_bold = [ + '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', + '/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc', + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + ] + mono_fonts = [ + '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + ] + + W = 1120 + pad = 50 + row_h = 32 + title_h = 190 + section_h = 42 + footer_h = 78 + max_rows = sum(len(v) for v in groups.values()) + H = title_h + footer_h + len(groups) * section_h + max_rows * row_h + 24 + H = max(500, H + 82) + + bg = (247, 249, 252) + card = (255, 255, 255) + line = (226, 232, 240) + line_soft = (241, 245, 249) + dark = (30, 41, 59) + muted = (100, 116, 139) + blue = (37, 99, 235) + + im = Image.new('RGB', (W, H), bg) + d = ImageDraw.Draw(im) + title_font = load_font(font_cjk_bold, 36) + meta_font = load_font(font_cjk, 21) + mono_font = load_font(mono_fonts, 23) + mono_small = load_font(mono_fonts, 20) + section_font = load_font(mono_fonts, 24) + status_font = load_font(font_cjk_bold, 23) + small_font = load_font(font_cjk, 20) + + d.rounded_rectangle([26, 22, W-26, H-24], radius=24, fill=card, outline=line, width=2) + + server_name = str(s.get('name') or s.get('host') or 'Server') + d.text((pad, 44), f'{server_name} 流媒体解锁测试', fill=dark, font=title_font) + d.text((W-pad, 52), source_repo('stream'), fill=muted, font=meta_font, anchor='ra') + d.text((pad, 92), f'{proto} · {region_label} · {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}', fill=muted, font=meta_font) + d.text((W-pad, 92), f'Yes {yes} No {no} Error {warn}', fill=blue, font=meta_font, anchor='ra') + if network: + nt = network[-1].replace('**', '').strip() + if len(nt) > 90: + nt = nt[:87] + '…' + d.text((pad, 126), nt, fill=muted, font=small_font) + d.line([pad, title_h-18, W-pad, title_h-18], fill=line, width=2) + + y = title_h + 4 + table_left = pad + table_right = W - pad + status_x = table_right + # mirror the script's tabbed layout: service name column then a fixed result column + name_col_width = 500 + + def fit_text(value, font, max_width): + value = str(value or '-') + if d.textlength(value, font=font) <= max_width: + return value + ell = '…' + while value and d.textlength(value + ell, font=font) > max_width: + value = value[:-1] + return value + ell + + def centered_rule(label): + text = f'[ {label} ]' + tw = d.textlength(text, font=section_font) + dash_w = d.textlength('=', font=section_font) + left_count = max(2, int((table_right - table_left - tw) / 2 / dash_w)) + right_count = left_count + return '=' * left_count + text + '=' * right_count + + for sec, items in groups.items(): + rule = fit_text(centered_rule(str(sec or '-')), section_font, table_right - table_left) + d.text(((W - d.textlength(rule, font=section_font)) / 2, y), rule, fill=blue, font=section_font) + y += section_h + for r in items: + name = fit_text((str(r.get('name') or '-').rstrip(':') + ':'), mono_font, name_col_width) + status = fit_text(stream_status_label(r.get('status'), r.get('extra')), status_font, 470) + color = stream_status_color(r.get('status'), r.get('extra')) + d.text((table_left, y), name, fill=dark, font=mono_font) + d.text((status_x, y), status, fill=color, font=status_font, anchor='ra') + y += row_h + end_rule = '=' * max(8, int((table_right - table_left) / max(d.textlength('=', font=mono_small), 1))) + d.text((table_left, y), fit_text(end_rule, mono_small, table_right - table_left), fill=line, font=mono_small) + y += 16 + + out_png = Path(out_png) + out_png.parent.mkdir(parents=True, exist_ok=True) + im.save(out_png, quality=95) + return out_png + + +async def remote_has_ipv4(s): + remote = "curl -4fsS --max-time 8 https://api.ipify.org >/dev/null" + code, _out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=15, env=ssh_env_for(s)) + return code == 0 + + + +def ansi_to_spans(text): + palette = { + 30: (51, 65, 85), 90: (100, 116, 139), + 31: (220, 38, 38), 91: (220, 38, 38), + 32: (22, 163, 74), 92: (22, 163, 74), + 33: (202, 138, 4), 93: (202, 138, 4), + 34: (37, 99, 235), 94: (37, 99, 235), + 35: (192, 38, 211), 95: (192, 38, 211), + 36: (8, 145, 178), 96: (8, 145, 178), + 37: (30, 41, 59), 97: (15, 23, 42), + } + default = (30, 41, 59) + spans = [] + color = default + bold = False + i = 0 + buf = '' + while i < len(text): + if text[i] == '\x1b' and i + 1 < len(text) and text[i + 1] == '[': + m = re.match(r'\x1b\[([0-9;]*)m', text[i:]) + if m: + if buf: + spans.append((buf, color, bold)) + buf = '' + codes = [int(x) if x else 0 for x in m.group(1).split(';')] + if not codes: + codes = [0] + for code in codes: + if code == 0: + color = default + bold = False + elif code == 1: + bold = True + elif code == 22: + bold = False + elif code in palette: + color = palette[code] + i += len(m.group(0)) + continue + buf += text[i] + i += 1 + if buf: + spans.append((buf, color, bold)) + return spans + + + +def terminal_char_width(ch): + o = ord(ch) + if o == 0: + return 0 + if o < 32 or 0x7f <= o < 0xa0: + return 0 + # CJK / fullwidth ranges; enough for NextTrace Chinese geo/ISP labels. + if ( + 0x1100 <= o <= 0x115f or 0x2e80 <= o <= 0xa4cf or + 0xac00 <= o <= 0xd7a3 or 0xf900 <= o <= 0xfaff or + 0xfe10 <= o <= 0xfe19 or 0xfe30 <= o <= 0xfe6f or + 0xff00 <= o <= 0xff60 or 0xffe0 <= o <= 0xffe6 + ): + return 2 + return 1 + + +def terminal_text_width(text): + return sum(terminal_char_width(ch) for ch in strip_ansi(text or '')) + + +def draw_terminal_spans(draw, x0, y, spans, *, cell_w, ascii_font, ascii_bold, cjk_font, cjk_bold): + col = 0 + for text, color, bold in spans: + for ch in text: + w = terminal_char_width(ch) + if w <= 0: + continue + is_cjk = w == 2 + font = (cjk_bold if bold else cjk_font) if is_cjk else (ascii_bold if bold else ascii_font) + # Draw every glyph onto a fixed terminal grid. This keeps NextTrace's original column layout + # while still letting CJK render with a CJK font. + draw.text((x0 + col * cell_w, y), ch, fill=color, font=font) + col += w + +def render_nexttrace_image(s, target, out, code, out_png): + raw = (out or '').replace('\r\n', '\n').replace('\r', '\n') + raw = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', lambda m: m.group(0) if m.group(0).endswith('m') else '', raw) + lines = [] + for line in raw.split('\n'): + if 'Generated by' in strip_ansi(line) or 'MapTrace URL:' in strip_ansi(line): + continue + lines.append(line.rstrip()) + while lines and not strip_ansi(lines[0]).strip(): + lines.pop(0) + while lines and not strip_ansi(lines[-1]).strip(): + lines.pop() + if not lines: + lines = ['无输出'] + + title_font = load_font([ + '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + ], 42) + mono_font = load_font([ + '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf', + ], 24) + mono_bold = load_font([ + '/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf', + ], 24) + cjk_font = load_font([ + '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', + ], 24) + cjk_bold = load_font([ + '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc', + ], 24) + + dummy = Image.new('RGB', (1, 1)) + d0 = ImageDraw.Draw(dummy) + char_w = max(13, int(d0.textlength('M', font=mono_font))) + line_h = 34 + max_cols = min(150, max(78, max((terminal_text_width(x) for x in lines), default=78))) + W = max(1180, min(2100, 112 + max_cols * char_w)) + H = 150 + max(1, len(lines)) * line_h + 42 + + bg = (248, 250, 252) + panel = (255, 255, 255) + border = (203, 213, 225) + fg = (15, 23, 42) + muted = (100, 116, 139) + accent = (2, 132, 199) + warn = (202, 138, 4) + err = (220, 38, 38) + + im = Image.new('RGB', (W, H), bg) + d = ImageDraw.Draw(im) + d.rounded_rectangle((28, 24, W - 28, H - 28), radius=24, fill=panel, outline=border, width=2) + d.rounded_rectangle((48, 44, W - 48, 118), radius=18, fill=(241, 245, 249), outline=(203, 213, 225), width=1) + title = 'NextTrace' + title_box = d.textbbox((0, 0), title, font=title_font) + title_h = title_box[3] - title_box[1] + title_y = 44 + (118 - 44 - title_h) / 2 - title_box[1] + d.text((72, title_y), title, fill=accent, font=title_font) + source_name = source_repo('nexttrace') + source_w = d.textlength(source_name, font=mono_font) + d.text((W - 72 - source_w, 72), source_name, fill=muted, font=mono_font) + if code != 0: + d.text((W - 260, 96), f'退出码 {code}', fill=warn, font=mono_font) + d.line((54, 126, W - 54, 126), fill=border, width=1) + + y = 148 + x0 = 68 + for line in lines: + x = x0 + plain = strip_ansi(line) + if plain.startswith('traceroute to') or 'hops max' in plain: + d.rounded_rectangle((54, y - 4, W - 54, y + line_h - 2), radius=8, fill=(248, 250, 252), outline=(226, 232, 240), width=1) + draw_terminal_spans( + d, x0, y, ansi_to_spans(line), + cell_w=char_w, + ascii_font=mono_font, + ascii_bold=mono_bold, + cjk_font=cjk_font, + cjk_bold=cjk_bold, + ) + y += line_h + + out_png = Path(out_png) + out_png.parent.mkdir(parents=True, exist_ok=True) + im.save(out_png, quality=95) + return out_png + +def nexttrace_prompt_text(s): + return ( + f'🛣 {safe(s.get("name"))} NextTrace\n\n' + '请直接发送要追踪的 IP 或域名。\n' + '例如:1.1.1.1cloudflare.com\n\n' + '也可以用命令:/nexttrace 服务器 目标IP或域名' + ) + + +def format_nexttrace_output(s, target, out, code): + clean = strip_ansi(out or '').replace('\r', '') + lines = [] + for raw in clean.splitlines(): + line = raw.rstrip() + if not line: + continue + skip = [ + 'NextTrace', 'nali', 'MapTrace', 'IP Geo Data Provider', + 'traceroute to', 'Generated by', '请勿用于商业用途', + ] + if any(x.lower() in line.lower() for x in skip): + continue + lines.append(line) + body = '\n'.join(lines).strip() or clean.strip() or '无输出' + body = trim_log(body, 3200) + title = f'🛣 {safe(s.get("name"))} NextTrace{safe(target)}' + if code != 0: + title = '⚠️ ' + title + f'\n退出码:{safe(code)}' + return title + '\n\n
' + safe(body) + '
' + + +async def run_nexttrace_task(bot, chat_id, s, jid, target='1.1.1.1'): + key = (server_id(s), 'nexttrace') + try: + qt = shlex.quote(str(target)) + trace_cmd = 'nexttrace ' + qt + remote = ( + "set -e; export TERM=xterm-256color COLORTERM=truecolor; export CLICOLOR_FORCE=1 FORCE_COLOR=1; " + "if ! command -v nexttrace >/dev/null 2>&1; then " + " curl -sL https://nxtrace.org/nt | bash >/tmp/nexttrace-install.log 2>&1 || " + " curl -Ls https://raw.githubusercontent.com/nxtrace/NTrace-core/main/nt_install.sh | bash >/tmp/nexttrace-install.log 2>&1; " + "fi; " + "script -qfec " + shlex.quote(trace_cmd) + " /dev/null 2>&1" + ) + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=300, env=ssh_env_for(s)) + JOBS[jid].update({'status': 'done' if code == 0 else 'failed', 'log': out, 'target': target}) + out_dir = Path('/tmp/guko-results') + out_dir.mkdir(parents=True, exist_ok=True) + png = out_dir / f"nexttrace-{server_id(s)}-{safe_target(target)}-{int(time.time())}.jpg" + try: + img = render_nexttrace_image(s, target, out, code, png) + except Exception: + img = None + if img: + saved = persist_result_file(s, 'nexttrace', img, '.jpg') + JOBS[jid].update({'media_path': saved}) + with img.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + await bot.send_message(chat_id, script_command_text('nexttrace', target=target)) + if code != 0: + await bot.send_message(chat_id, '提示:NextTrace 退出码不为 0,图片是已抓到的部分结果。') + else: + await bot.send_message(chat_id, format_nexttrace_output(s, target, out, code), parse_mode=ParseMode.HTML, disable_web_page_preview=True) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e), 'target': target}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} NextTrace 失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +async def run_stream_task(bot, chat_id, s, jid): + key = (server_id(s), 'stream') + try: + region_id, region_label = stream_region_for_server(s) + use_v4 = await remote_has_ipv4(s) + proto_arg = '-M 4' if use_v4 else '-M 6' + proto_text = 'IPv4' if use_v4 else 'IPv6(无 IPv4,自动切换)' + remote = ( + "export TERM=xterm-256color; cd /tmp; " + "script=$(mktemp /tmp/stream-unlock.XXXXXX.sh); " + "curl -4LfsS --max-time 30 check.unlock.media -o $script || " + "curl -4LfsS --max-time 30 http://check.unlock.media -o $script || " + "curl -4LfsS --max-time 30 https://raw.githubusercontent.com/lmc999/RegionRestrictionCheck/main/check.sh -o $script || " + "curl -6LfsS --max-time 30 https://raw.githubusercontent.com/lmc999/RegionRestrictionCheck/main/check.sh -o $script; " + "bash $script " + proto_arg + " -R " + shlex.quote(region_id) + " 2>&1" + ) + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=1800, env=ssh_env_for(s)) + JOBS[jid].update({'status': 'done' if code == 0 else 'failed', 'log': out, 'proto': proto_text, 'region': region_label}) + out_dir = Path('/tmp/guko-results') + out_dir.mkdir(parents=True, exist_ok=True) + png = out_dir / f"stream-{server_id(s)}-{int(time.time())}.jpg" + try: + img = stream_result_image(s, out, proto_text, region_label, region_id, png) + except Exception: + img = None + if img: + saved = persist_result_file(s, 'stream', img, '.jpg') + JOBS[jid].update({'media_path': saved}) + with img.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + await bot.send_message(chat_id, script_command_text('stream', proto_arg=proto_arg, region_id=region_id)) + if code != 0: + await bot.send_message(chat_id, '提示:脚本退出码不为 0,图片是已抓到的部分结果。') + else: + msg = format_stream_summary(s, out, proto_text, region_label, region_id) + if code != 0: + msg = '⚠️ 脚本退出码不为 0,但下面是已抓到的输出:\n\n' + msg + await bot.send_message(chat_id, msg, parse_mode=ParseMode.HTML, disable_web_page_preview=True) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e)}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} 流媒体检测失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +async def run_nq_task(bot, chat_id, s, jid, mask=NQ_ALL_MASK, ip_mode='4'): + key = (server_id(s), 'nq') + try: + selected_text = nq_selected_text(mask) + ip_text = nq_ip_mode_text(ip_mode) + answers = nq_answer_script(mask) + ipv_arg = nq_remote_ipv_arg(s, ip_mode) + # Patch Net.Check.Place's TCP large-packet mtr probes with a per-probe + # timeout. On some VPSes mtr can hang forever at the 09% delay stage, + # leaving empty net_quality.json/result.zip. + remote = ( + "export TERM=xterm-256color; cd /root && " + "script=$(mktemp /root/nodequality.XXXXXX.sh); " + "curl -sL https://run.NodeQuality.com > $script; " + "sed -i 's#rm -rf \\\"${work_dir}\\\"/#: # rm -rf \\\"${work_dir}\\\"/#' $script; " + "sed -i 's#response=$(\\$pingcom 2>\\&1)#response=$(timeout -s SIGKILL 15 $pingcom 2>\\&1)#' $script; " + "printf %b " + shlex.quote(answers) + " | bash $script " + ipv_arg + ) + code, out = await run_subprocess(ssh_args(s, remote, tty=False), timeout=7200, env=ssh_env_for(s)) + JOBS[jid].update({'log': out, 'selected': selected_text, 'ip_mode': ip_text}) + nq = nodequality_url(out) + gb_urls = geekbench_urls(out) + # Keep the URL generated by the official NodeQuality script. Re-uploading + # result.zip can create a record that opens with `bad token` / JSON parse + # errors on nodequality.com, so do not replace the original link here. + report_links = [] + for label, cat, bit in [('硬件', 'hardware', 1), ('IP质量', 'ip', 2), ('网络', 'net', 4), ('回程', 'backroute', 8)]: + if not (mask & bit): + continue + urls = all_report_urls(out, cat) + if urls: + report_links.append((label, urls[-1])) + if not report_links: + report_links = await recover_report_links_from_remote(s, mask) + image_ok = False + image_error = '' + # Full NodeQuality already has a combined result page; avoid sending + # multiple分项 screenshots. Partial/single selections still send images. + if report_links and mask != NQ_ALL_MASK: + try: + sent = await send_report_images(bot, chat_id, report_links, f"nq-{server_id(s)}") + media_paths = [] + clear_result_files(s, 'nq') + for label, url, png in sent: + saved = persist_result_file(s, 'nq', png, f'-{label}.png', clear=False) + if saved: + media_paths.append(saved) + if media_paths: + JOBS[jid].update({'media_paths': media_paths}) + image_ok = True + except Exception as e: + image_error = str(e) + final_ok = bool(nq or report_links or image_ok) + JOBS[jid].update({'status': 'done' if final_ok else 'failed'}) + msg = f"✅ {safe(s.get('name'))} NodeQuality 完成:{safe(selected_text)};{safe(ip_text)}" if final_ok else f"❌ {safe(s.get('name'))} NodeQuality 失败:{safe(selected_text)};{safe(ip_text)}" + if nq: + msg += f"\n\nNodeQuality:\n{safe(nq)}" + if gb_urls: + msg += "\n\nGeekbench:\n" + "\n".join(safe(u) for u in gb_urls) + if mask != NQ_ALL_MASK and not image_ok and report_links: + msg += "\n\n分项报告:\n" + "\n".join(f"- {safe(label)}: {safe(url)}" for label, url in report_links) + if image_error: + msg += f"\n\n转 PNG 失败:{safe(image_error)}" + if not nq and not report_links: + msg += f"\n\n没解析到结果链接,末尾日志:\n
{safe(trim_log(out))}
" + msg += f"\n\n{script_command_html('nq', selected=selected_text, ip_mode=ip_text)}" + await bot.send_message(chat_id, msg, parse_mode=ParseMode.HTML, disable_web_page_preview=True) + except Exception as e: + JOBS[jid].update({'status': 'failed', 'log': repr(e)}) + await bot.send_message(chat_id, f"❌ {safe(s.get('name'))} NQ任务失败:{safe(e)}", parse_mode=ParseMode.HTML) + finally: + finish_job(jid, key) + + +async def send_history_result(bot, chat_id, s, kind): + item = history_item_for(s, kind) + if not item: + await bot.send_message(chat_id, f'暂无 {safe(KIND_NAME.get(kind, kind))} 历史。', parse_mode=ParseMode.HTML) + return False + media_paths = [] + for x in item.get('media_paths') or []: + p = Path(x) + if p.exists() and p.is_file() and p.stat().st_size > 0: + media_paths.append(p) + if kind == 'nq': + urls = item.get('urls') or [] + nq = next((u for u in urls if 'nodequality.com/r/' in u), None) + gb_urls = [u for u in urls if 'browser.geekbench.com/' in u] + report_urls = [u for u in urls if 'Report.Check.Place/' in u] + selected = item.get('selected') or '-' + ip_mode = item.get('ip_mode') or '-' + is_full_nq = selected == nq_selected_text(NQ_ALL_MASK) + if not is_full_nq: + for media in media_paths: + with media.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + msg = f"✅ {safe(s.get('name'))} NodeQuality 完成:{safe(selected)};{safe(ip_mode)}" + if nq: + msg += f"\n\nNodeQuality:\n{safe(nq)}" + if gb_urls: + msg += "\n\nGeekbench:\n" + "\n".join(safe(u) for u in gb_urls) + if not media_paths and report_urls: + msg += "\n\n分项报告:\n" + "\n".join(f"- {safe(u)}" for u in report_urls) + msg += f"\n\n{script_command_html('nq', selected=selected, ip_mode=ip_mode)}" + await bot.send_message(chat_id, msg, parse_mode=ParseMode.HTML, disable_web_page_preview=True) + return True + if media_paths: + for media in media_paths: + with media.open('rb') as f: + await bot.send_photo(chat_id, photo=f) + return True + await bot.send_message(chat_id, history_detail_text(s, kind), parse_mode=ParseMode.HTML, disable_web_page_preview=True) + return True + + +async def send_or_edit(update: Update, text, markup=None): + if update.callback_query: + await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=markup) + else: + await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + await send_or_edit(update, menu_text(), main_menu_markup()) + + +async def version_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + await update.message.reply_text(f'GUKO v{GUKO_VERSION}') + + +async def list_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + await send_or_edit(update, menu_text(), main_menu_markup()) + + +async def status_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + await send_or_edit(update, menu_text(), main_menu_markup()) + + +async def addserver_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await admin_guard(update): return + clear_add_session(update.effective_chat.id) + await update.message.reply_text(add_help_text(), parse_mode=ParseMode.HTML, reply_markup=add_start_markup()) + + +async def info_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + if not context.args: + await update.message.reply_text('用法:/info <名字/IP/ID/别名>') + return + s = find_server(' '.join(context.args), load_inventory().get('servers', [])) + if not s: + await update.message.reply_text('没找到这台。') + return + await update.message.reply_text(await server_detail_text_with_specs(s), parse_mode=ParseMode.HTML, reply_markup=server_markup(s)) + + +async def export_config_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await admin_guard(update): return + data = redact_inventory(load_inventory()) + text = json.dumps(data, ensure_ascii=False, indent=2) + '\n' + path = TMP_DIR / f'guko-export-redacted-{int(time.time())}.json' + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + with path.open('rb') as f: + await update.message.reply_document(document=f, filename='guko-servers-redacted.json', caption='已导出脱敏配置(密码已隐藏)。') + + +async def testall_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await admin_guard(update): return + servers = [s for s in load_inventory().get('servers', []) if s.get('host')] + if not servers: + await update.message.reply_text('当前没有服务器。') + return + msg = await update.message.reply_text(f'🧪 开始批量测试 SSH:{len(servers)} 台,并发 3。') + sem = asyncio.Semaphore(3) + results = [] + async def one(s): + async with sem: + ok, out = await test_server_login(s, timeout=12) + results.append((s, ok, out)) + await asyncio.gather(*(one(s) for s in servers)) + lines = [] + for s, ok, out in results: + mark = '✅' if ok else '❌' + cfg = ssh_config(s) + lines.append(f'{mark} {s.get("name")} {cfg.get("user")}@{cfg.get("host")}:{cfg.get("port")}') + if not ok: + lines.append(' ' + strip_ansi(out).splitlines()[-1][:120] if out else ' 无输出') + await msg.edit_text('
' + safe('\n'.join(lines)[-3500:]) + '
', parse_mode=ParseMode.HTML) + + +async def testssh_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await admin_guard(update): return + if not context.args: + await update.message.reply_text('用法:/testssh <名字/IP/ID/别名>') + return + s = find_server(' '.join(context.args), load_inventory().get('servers', [])) + if not s: + await update.message.reply_text('没找到这台服务器。') + return + msg = await update.message.reply_text(f'🧪 正在测试 {safe(s.get("name"))} SSH…', parse_mode=ParseMode.HTML) + ok, out = await test_server_login(s) + await msg.edit_text(('✅ SSH 登录成功:' if ok else '⚠️ SSH 登录失败:') + '
' + safe(out[-1200:]) + '
', parse_mode=ParseMode.HTML) + + +async def history_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + if not context.args: + await update.message.reply_text('用法:/history <名字/IP/ID/别名>') + return + s = find_server(' '.join(context.args), load_inventory().get('servers', [])) + if not s: + await update.message.reply_text('没找到这台服务器。') + return + await update.message.reply_text(history_text(s), parse_mode=ParseMode.HTML, disable_web_page_preview=True, reply_markup=history_markup(s)) + + +async def jobs_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + if not JOBS: + await update.message.reply_text('暂无后台任务。') + return + lines = [] + for jid, j in list(JOBS.items())[-10:]: + lines.append(f"{jid}: {j.get('server')} {j.get('kind')} {j.get('status')}") + await update.message.reply_text('
' + safe('\n'.join(lines)) + '
', parse_mode=ParseMode.HTML) + + +async def document_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + chat_id = update.effective_chat.id if update.effective_chat else None + sess = ADD_SESSIONS.get(chat_id) if chat_id is not None else None + if not sess or sess.get('step') not in ('single_key_text', 'bulk_shared_key_text'): + await update.message.reply_text('收到文件了,但当前没有等待密钥上传。请先点“添加服务器”。') + return + doc = update.message.document + if not doc: + return + if doc.file_size and doc.file_size > 128 * 1024: + await update.message.reply_text('密钥文件太大了,不像 SSH 私钥。') + return + await update.message.chat.send_action(ChatAction.TYPING) + f = await doc.get_file() + data = await f.download_as_bytearray() + try: + key_path = save_private_key(chat_id, bytes(data), doc.file_name or 'telegram-key') + except Exception as e: + await update.message.reply_text(f'密钥识别失败:{safe(e)}', parse_mode=ParseMode.HTML) + return + if sess.get('step') == 'single_key_text': + add_session(chat_id, key=key_path, auth_kind='key') + await finish_single_add(update, context, ADD_SESSIONS[chat_id]) + else: + add_session(chat_id, auth_mode='key', shared_auth=key_path, step='bulk_lines') + await update.message.reply_text('密钥已保存。现在发送服务器列表,每行一台。\n\n格式:名称 IP 用户', parse_mode=ParseMode.HTML) + + +async def fallback_panel(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + text = update.message.text or '' + chat_id = update.effective_chat.id if update.effective_chat else None + sess = ADD_SESSIONS.get(chat_id) if chat_id is not None else None + if sess: + step = sess.get('step') + if step == 'single_basic': + parts = shlex.split(text) + if len(parts) < 2: + await update.message.reply_text('格式:名称 IP [端口] [用户]\n例:hk-01 1.2.3.4 22 root', parse_mode=ParseMode.HTML) + return + host, embedded_port = parse_host_port(parts[1]) + if not is_valid_hostname(host): + await update.message.reply_text('IP/域名看起来不对,重新发一次。') + return + port = embedded_port + user = 'root' + if len(parts) >= 3 and parts[2].isdigit(): + port = int(parts[2]) + if len(parts) >= 4: + user = parts[3] + elif len(parts) >= 3: + user = parts[2] + add_session(chat_id, step='single_auth', name=parts[0], host=host, port=port or 22, user=user) + await update.message.reply_text('选择这台服务器的登录方式:', reply_markup=add_auth_markup()) + return + if step == 'single_password': + add_session(chat_id, password=text.strip(), auth_kind='password') + await finish_single_add(update, context, ADD_SESSIONS[chat_id]) + return + if step == 'single_key_path': + key_path = os.path.expanduser(text.strip()) + if not key_path: + await update.message.reply_text('密钥路径不能为空。') + return + add_session(chat_id, key=key_path, auth_kind='key') + await finish_single_add(update, context, ADD_SESSIONS[chat_id]) + return + if step == 'single_key_text': + try: + key_path = save_private_key(chat_id, text, 'telegram-key') + except Exception as e: + await update.message.reply_text(f'密钥识别失败:{safe(e)}', parse_mode=ParseMode.HTML) + return + add_session(chat_id, key=key_path, auth_kind='key') + await finish_single_add(update, context, ADD_SESSIONS[chat_id]) + return + if step == 'bulk_port': + if not text.strip().isdigit(): + await update.message.reply_text('端口需要是数字,比如 22 或 53580。') + return + add_session(chat_id, same_port=int(text.strip()), step='bulk_auth') + await update.message.reply_text('端口已设置。现在选择认证方式:', reply_markup=bulk_auth_markup()) + return + if step == 'bulk_shared_password': + add_session(chat_id, auth_mode='password', shared_auth=text.strip(), step='bulk_lines') + await update.message.reply_text('密码已记录。现在发送服务器列表,每行一台。\n\n格式:名称 IP 用户', parse_mode=ParseMode.HTML) + return + if step == 'bulk_shared_key_text': + try: + key_path = save_private_key(chat_id, text, 'bulk-key') + except Exception as e: + await update.message.reply_text(f'密钥识别失败:{safe(e)}', parse_mode=ParseMode.HTML) + return + add_session(chat_id, auth_mode='key', shared_auth=key_path, step='bulk_lines') + await update.message.reply_text('密钥已保存。现在发送服务器列表,每行一台。\n\n格式:名称 IP 用户', parse_mode=ParseMode.HTML) + return + if step == 'bulk_lines': + await finish_bulk_add(update, context, sess, text) + return + if step == 'edit_value': + sid = sess.get('sid') + field = sess.get('field') + s = find_server_by_id(sid) + if not s: + clear_add_session(chat_id) + await update.message.reply_text('这台服务器不在当前清单里。') + return + val = text.strip() + patch = {} + if field == 'name': + patch['name'] = val + elif field == 'host': + host, embedded_port = parse_host_port(val) + if not is_valid_hostname(host): + await update.message.reply_text('IP/域名看起来不对,重新发一次。') + return + patch['host'] = host + if embedded_port: + patch['ssh'] = {'port': embedded_port} + elif field == 'port': + if not val.isdigit(): + await update.message.reply_text('端口需要是数字。') + return + patch['ssh'] = {'port': int(val)} + elif field == 'user': + patch['ssh'] = {'user': val} + elif field == 'key': + patch['ssh'] = {'auth': 'key', 'key': os.path.expanduser(val)} + elif field == 'password': + patch['ssh'] = {'auth': 'password', 'password': val} + updated = update_server_by_id(sid, patch) + clear_add_session(chat_id) + await update.message.reply_text(f'已更新:{safe(updated.get("name"))}', parse_mode=ParseMode.HTML, reply_markup=server_markup(updated)) + return + pending_sid = PENDING_NEXTTRACE.pop(chat_id, None) if chat_id is not None else None + if pending_sid: + s = find_server_by_id(pending_sid) + if not s: + await update.message.reply_text('刚才选择的服务器不在当前清单里。') + return + target = extract_ipv4(text) or normalize_domain(text) + if not target: + await update.message.reply_text('没识别到 IP 或域名,已取消这次 NextTrace。') + return + jid = launch_job(s, 'nexttrace', run_nexttrace_task, context.bot, chat_id, s, target, target=target) + await bot_task_started_notice(context.bot, chat_id, s, f'NextTrace {safe(target)}', jid is not None) + return + # 普通文本不再触发任何功能;只有点击 NextTrace 后的下一条 IP/域名才会被消费。 + return + + +async def post_init(app: Application): + commands = [ + BotCommand('start', '打开 GUKO 面板'), + BotCommand('list', '服务器列表'), + BotCommand('status', '总览状态'), + BotCommand('addserver', '添加/批量导入服务器'), + BotCommand('testssh', '测试服务器 SSH'), + BotCommand('testall', '批量测试 SSH'), + BotCommand('exportconfig', '导出脱敏配置'), + BotCommand('info', '查看单台操作面板:/info 名字/IP/ID'), + BotCommand('jobs', '查看后台任务'), + BotCommand('history', '查看测试历史:/history 服务器'), + BotCommand('ip', 'IP/域名工具:/ip 1.1.1.1'), + BotCommand('nexttrace', '路由追踪:/nexttrace 服务器 目标'), + BotCommand('version', '查看 GUKO 版本'), + ] + await app.bot.set_my_commands(commands) + + +async def ip_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + if not context.args: + await update.message.reply_text('用法:/ip ') + return + target = ' '.join(context.args) + try: + ip, host = await resolve_target_to_ipv4(target) + suffix = f'{safe(host)} → {safe(ip)}' if host else f'{safe(ip)}' + await update.message.reply_text(f'识别到 {suffix},选一个生成:', parse_mode=ParseMode.HTML, reply_markup=ip_tools_markup(ip)) + except Exception as e: + await update.message.reply_text(f'解析失败:{safe(e)}', parse_mode=ParseMode.HTML) + + +async def nexttrace_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + if not context.args: + await update.message.reply_text('用法:/nexttrace <服务器名/IP/ID/别名> [目标IP或域名]\n例:/nexttrace 海创 1.1.1.1') + return + s = find_server(context.args[0], load_inventory().get('servers', [])) + if not s: + await update.message.reply_text('没找到这台服务器。') + return + target = context.args[1] if len(context.args) > 1 else '1.1.1.1' + if not (extract_ipv4(target) or normalize_domain(target)): + await update.message.reply_text('目标需要是 IPv4 或域名。') + return + jid = launch_job(s, 'nexttrace', run_nexttrace_task, context.bot, update.effective_chat.id, s, target, target=target) + if jid: + await update.message.reply_text(f'🛣 已启动 {safe(s.get("name"))} NextTrace:{safe(target)}', parse_mode=ParseMode.HTML) + else: + await update.message.reply_text(f'这个任务已经在运行中:{safe(s.get("name"))} NextTrace:{safe(target)}', parse_mode=ParseMode.HTML) + + +async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not await guard(update): return + q = update.callback_query + await q.answer() + data = q.data or '' + chat_id = q.message.chat_id if q.message else update.effective_chat.id + if data == 'add:start': + if not await admin_guard(update): return + clear_add_session(chat_id) + await q.edit_message_text(add_help_text(), parse_mode=ParseMode.HTML, reply_markup=add_start_markup()) + elif data == 'add:one': + if not await admin_guard(update): return + add_session(chat_id, step='single_basic') + await q.edit_message_text( + '➕ 发送服务器信息:\n\n' + '名称 IP [端口] [用户]\n\n' + '例:hk-01 1.2.3.4 22 root\n' + '也支持:hk-01 1.2.3.4:53580 root', + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]]) + ) + elif data == 'add:bulk': + if not await admin_guard(update): return + add_session(chat_id, step='bulk_choose_port') + await q.edit_message_text(bulk_help_text() + '\n\n先选择端口策略:', parse_mode=ParseMode.HTML, reply_markup=bulk_mode_markup()) + elif data == 'add:cancel': + clear_add_session(chat_id) + await q.edit_message_text('已取消添加服务器。', reply_markup=main_menu_markup()) + elif data == 'addauth:default': + defaults = inventory_defaults(load_inventory()) + add_session(chat_id, auth_kind='default') + await q.edit_message_text( + f'将沿用默认 SSH 配置测试登录:\n{safe(defaults.get("user"))}@服务器:{safe(defaults.get("port"))}\n密钥:{safe(defaults.get("key"))}', + parse_mode=ParseMode.HTML, + ) + await finish_single_add(update, context, ADD_SESSIONS[chat_id]) + elif data == 'addauth:keypath': + add_session(chat_id, step='single_key_path') + await q.edit_message_text('请发送已有 SSH 私钥路径,例如:\n/data/keys/id_ed25519\n\n适合新服务器继续使用以前同一把密钥。', parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'addauth:key': + add_session(chat_id, step='single_key_text') + await q.edit_message_text('请直接发送 SSH 私钥文本,或以文件形式上传私钥。\n\n需要包含 SSH 私钥的 BEGIN/END 头尾标记。', parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'addauth:password': + add_session(chat_id, step='single_password') + await q.edit_message_text('请发送这台服务器的 SSH 密码。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'addauth:skip': + add_session(chat_id, auth_kind=None) + await finish_single_add(update, context, ADD_SESSIONS[chat_id]) + elif data == 'bulkport:same': + add_session(chat_id, step='bulk_port') + await q.edit_message_text('请输入所有服务器共用的 SSH 端口,例如 2253580。', parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'bulkport:per': + add_session(chat_id, same_port=None, step='bulk_auth') + await q.edit_message_text('好,每台服务器自己写端口。现在选择认证方式:', reply_markup=bulk_auth_markup()) + elif data == 'bulkauth:key': + add_session(chat_id, step='bulk_shared_key_text') + await q.edit_message_text('请发送所有服务器共用的 SSH 私钥文本,或上传私钥文件。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'bulkauth:password': + add_session(chat_id, step='bulk_shared_password') + await q.edit_message_text('请发送所有服务器共用的 SSH 密码。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'bulkauth:per': + add_session(chat_id, auth_mode='per', step='bulk_lines') + await q.edit_message_text( + '请发送服务器列表,每行一台:\n\n' + '名称 IP 端口 用户 key:/data/keys/a\n' + '名称 IP 端口 用户 password:你的密码', + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]]) + ) + elif data == 'bulkauth:skip': + add_session(chat_id, auth_mode='skip', step='bulk_lines') + await q.edit_message_text('请发送服务器列表,每行一台:\n\n名称 IP [端口] [用户]', parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data == 'noop': + await q.answer('该功能未启用', show_alert=True) + elif data == 'act:list': + await send_or_edit(update, menu_text(), main_menu_markup()) + elif data.startswith('edit:'): + if not await admin_guard(update): return + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + cfg = ssh_config(s) + await q.edit_message_text( + f'✏️ 编辑 {safe(s.get("name"))}\n{safe(cfg.get("user"))}@{safe(cfg.get("host"))}:{safe(cfg.get("port"))}', + parse_mode=ParseMode.HTML, + reply_markup=edit_markup(s), + ) + elif data.startswith('editfield:'): + if not await admin_guard(update): return + _p, sid, field = data.split(':', 2) + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=main_menu_markup()) + return + add_session(chat_id, step='edit_value', sid=sid, field=field) + labels = {'name': '新名称', 'host': '新 IP/域名(可带 :端口)', 'port': '新端口', 'user': '新 SSH 用户名', 'key': '新密钥路径', 'password': '新密码'} + await q.edit_message_text(f'请发送{labels.get(field, "新值")}:', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('❌ 取消', callback_data='add:cancel')]])) + elif data.startswith('editdefault:'): + if not await admin_guard(update): return + sid = data.split(':', 1)[1] + updated = update_server_by_id(sid, {'ssh': {'auth': 'key', 'key': None}}) + if not updated: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=main_menu_markup()) + return + await q.edit_message_text(f'已改为沿用默认密钥:{safe(updated.get("name"))}', parse_mode=ParseMode.HTML, reply_markup=server_markup(updated)) + elif data.startswith('jobsrv:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + await q.edit_message_text( + job_status_text(s), + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton('🔄 刷新任务', callback_data=f'jobsrv:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]), + ) + elif data.startswith('hist:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + await q.edit_message_text( + history_text(s), + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=history_markup(s), + ) + elif data.startswith('histd:'): + parts = data.split(':', 2) + sid = parts[1] if len(parts) > 1 else '' + kind = parts[2] if len(parts) > 2 else '' + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + item = history_item_for(s, kind) + if item and (latest_media_path(item) or kind == 'nq'): + await send_history_result(context.bot, q.message.chat_id, s, kind) + await q.edit_message_text( + f'已重新发送 {safe(KIND_NAME.get(kind, kind))} 最近一次完整结果。', + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton('↩️ 返回历史记录', callback_data=f'hist:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]), + ) + else: + await q.edit_message_text( + history_detail_text(s, kind), + parse_mode=ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton('↩️ 返回历史记录', callback_data=f'hist:{sid}')], + [InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')], + ]), + ) + elif data.startswith('testssh:'): + if not await admin_guard(update): return + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + await q.edit_message_text(f'🧪 正在测试 {safe(s.get("name"))} SSH…', parse_mode=ParseMode.HTML) + ok, out = await test_server_login(s) + await q.edit_message_text( + (f'✅ {safe(s.get("name"))} SSH 登录成功:' if ok else f'⚠️ {safe(s.get("name"))} SSH 登录失败:') + '
' + safe(out[-1200:]) + '
', + parse_mode=ParseMode.HTML, + reply_markup=server_markup(s), + ) + elif data.startswith('delask:'): + if not await admin_guard(update): return + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + cfg = ssh_config(s) + await q.edit_message_text( + '⚠️ 确认删除这台服务器?\n\n' + f'{safe(s.get("name"))}\n' + f'{safe(cfg.get("user"))}@{safe(cfg.get("host"))}:{safe(cfg.get("port"))}\n\n' + '只会从 GUKO 配置删除,不会动远端机器。', + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton('✅ 确认删除', callback_data=f'delconfirm:{sid}')], + [InlineKeyboardButton('❌ 取消删除', callback_data=f'srv:{sid}')], + ]), + ) + elif data.startswith('delconfirm:'): + if not await admin_guard(update): return + sid = data.split(':', 1)[1] + removed = delete_server_by_id(sid) + if not removed: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=main_menu_markup()) + return + await q.edit_message_text(f'已从配置删除:{safe(removed.get("name"))}', parse_mode=ParseMode.HTML, reply_markup=main_menu_markup()) + elif data.startswith('proxy:'): + parts = data.split(':', 2) + kind = parts[1] + sid = parts[2] if len(parts) > 2 else '' + s = find_server_by_id(sid) + if kind not in PROXY_TOOLS or not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + await q.edit_message_text(proxy_menu_text(s, kind), parse_mode=ParseMode.HTML, reply_markup=proxy_markup(s, kind)) + elif data.startswith('proxyrun:'): + parts = data.split(':', 3) + kind = parts[1] if len(parts) > 1 else '' + action = parts[2] if len(parts) > 2 else '' + sid = parts[3] if len(parts) > 3 else '' + s = find_server_by_id(sid) + if kind not in PROXY_TOOLS or action not in ('install', 'ensure', 'view') or not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + tool = proxy_tool_config(kind) + task = f"{safe(tool['name'])} {'安装/更新检查' if action in ('install', 'ensure') else '查看配置'}" + jid = launch_job(s, kind, run_proxy_tool_task, context.bot, q.message.chat_id, s, kind, action, target=action) + await bot_task_started_notice(context.bot, q.message.chat_id, s, task, jid is not None) + + elif data.startswith('vlessmode:'): + parts = data.split(':', 2) + mode = parts[1] if len(parts) > 1 else 'plain' + sid = parts[2] if len(parts) > 2 else '' + s = find_server_by_id(sid) + if mode not in ('plain', 'reality') or not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + tool = proxy_tool_config('vless') + label = 'Vision + Reality' if mode == 'reality' else '纯 VLESS' + task = f"{safe(tool['name'])} {label} 安装/更新" + jid = launch_job(s, f'vless-{mode}', run_proxy_tool_task, context.bot, q.message.chat_id, s, 'vless', 'ensure', mode, target=mode) + await bot_task_started_notice(context.bot, q.message.chat_id, s, task, jid is not None) + elif data.startswith('ipq:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + jid = launch_job(s, 'ipq', run_ip_quality_task, context.bot, q.message.chat_id, s) + await bot_task_started_notice(context.bot, q.message.chat_id, s, 'IP质量任务', jid is not None) + elif data.startswith('gb5:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + jid = launch_job(s, 'gb5', run_gb5_task, context.bot, q.message.chat_id, s) + await bot_task_started_notice(context.bot, q.message.chat_id, s, 'GB5', jid is not None) + elif data.startswith('stream:') or data.startswith('streamrun:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + region_id, region_label = stream_region_for_server(s) + jid = launch_job(s, 'stream', run_stream_task, context.bot, q.message.chat_id, s, region=region_label) + await bot_task_started_notice(context.bot, q.message.chat_id, s, f'流媒体检测({safe(region_label)})', jid is not None) + elif data.startswith('ntask:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + PENDING_NEXTTRACE[q.message.chat_id] = sid + await q.edit_message_text( + nexttrace_prompt_text(s), + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回操作面板', callback_data=f'srv:{sid}')]]) + ) + elif data.startswith('ntrun:'): + parts = data.split(':', 2) + sid = parts[1] + target = parts[2] if len(parts) > 2 else '1.1.1.1' + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + jid = launch_job(s, 'nexttrace', run_nexttrace_task, context.bot, q.message.chat_id, s, target, target=target) + await bot_task_started_notice(context.bot, q.message.chat_id, s, f'NextTrace {safe(target)}', jid is not None) + elif data.startswith('bgp:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + jid = launch_job(s, 'bgp', run_bgp_task, context.bot, q.message.chat_id, s) + await bot_task_started_notice(context.bot, q.message.chat_id, s, 'BGP 图任务', jid is not None) + elif data.startswith('ippure:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + jid = launch_job(s, 'ippure', run_ippure_task, context.bot, q.message.chat_id, s) + await bot_task_started_notice(context.bot, q.message.chat_id, s, 'IPPure 图任务', jid is not None) + elif data.startswith('bgpip:'): + ip = data.split(':', 1)[1] + if not is_ipv4(ip): + await q.answer('无效 IPv4', show_alert=True) + return + pseudo = {'id': ip, 'name': ip, 'host': ip} + jid, key = start_job(pseudo, 'bgp') + if not jid: + await q.answer('这个 IP 的 BGP 图已经在生成了', show_alert=True) + return + await send_running_notice(context.bot, q.message.chat_id, pseudo, 'BGP 图任务') + asyncio.create_task(run_bgp_task(context.bot, q.message.chat_id, pseudo, jid)) + elif data.startswith('ippureip:'): + ip = data.split(':', 1)[1] + if not is_ipv4(ip): + await q.answer('无效 IPv4', show_alert=True) + return + pseudo = {'id': ip, 'name': ip, 'host': ip} + jid, key = start_job(pseudo, 'ippure') + if not jid: + await q.answer('这个 IP 的 IPPure 图已经在生成了', show_alert=True) + return + await send_running_notice(context.bot, q.message.chat_id, pseudo, 'IPPure 图任务') + asyncio.create_task(run_ippure_task(context.bot, q.message.chat_id, pseudo, jid)) + elif data.startswith('nqask:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + mask = NQ_DEFAULT_MASK + await q.edit_message_text( + nq_menu_text(s, mask, '4'), + parse_mode=ParseMode.HTML, reply_markup=confirm_nq_markup(s, mask, '4') + ) + elif data.startswith('nqtoggle:') or data.startswith('nqsel:') or data.startswith('nqproto:'): + parts = data.split(':') + _kind, sid = parts[0], parts[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + try: + mask = int(parts[2]) & NQ_ALL_MASK + except Exception: + mask = NQ_DEFAULT_MASK + ip_mode = parts[3] if len(parts) > 3 else '4' + if ip_mode == '46' and not server_has_ipv6(s): + ip_mode = '4' + await q.edit_message_text( + nq_menu_text(s, mask, ip_mode), + parse_mode=ParseMode.HTML, reply_markup=confirm_nq_markup(s, mask, ip_mode) + ) + elif data.startswith('nqrun:'): + parts = data.split(':') + sid = parts[1] + try: + mask = int(parts[2]) & NQ_ALL_MASK if len(parts) > 2 else NQ_DEFAULT_MASK + except Exception: + mask = NQ_DEFAULT_MASK + ip_mode = parts[3] if len(parts) > 3 else '4' + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + if mask == 0: + await q.answer('至少选一项', show_alert=True) + await q.edit_message_text( + nq_menu_text(s, mask, ip_mode) + '\n\n至少选一项才能开始。', + parse_mode=ParseMode.HTML, reply_markup=confirm_nq_markup(s, mask, ip_mode) + ) + return + if ip_mode == '46' and not server_has_ipv6(s): + ip_mode = '4' + selected_text = nq_selected_text(mask) + ip_text = nq_ip_mode_text(ip_mode) + jid = launch_job(s, 'nq', run_nq_task, context.bot, q.message.chat_id, s, mask, ip_mode, selected=selected_text, ip_mode=ip_text) + await bot_task_started_notice(context.bot, q.message.chat_id, s, f'NodeQuality({safe(selected_text)};{safe(ip_text)})', jid is not None) + elif data.startswith('srv:'): + sid = data.split(':', 1)[1] + s = find_server_by_id(sid) + if not s: + await q.edit_message_text('这台服务器不在当前清单里。', reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton('↩️ 返回列表', callback_data='act:list')]])) + return + if not format_server_specs(s.get('specs')): + await q.edit_message_text(server_detail_text(s) + '\n正在读取配置…', parse_mode=ParseMode.HTML) + await q.edit_message_text(await server_detail_text_with_specs(s), parse_mode=ParseMode.HTML, reply_markup=server_markup(s)) + + +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE): + print(f'bot error: {context.error!r}', flush=True) + + +def main(): + startup_check() + app = Application.builder().token(BOT_TOKEN).post_init(post_init).build() + app.add_handler(CommandHandler(['start', 'help'], start)) + app.add_handler(CommandHandler('version', version_cmd)) + app.add_handler(CommandHandler('list', list_cmd)) + app.add_handler(CommandHandler('status', status_cmd)) + app.add_handler(CommandHandler('addserver', addserver_cmd)) + app.add_handler(CommandHandler('testssh', testssh_cmd)) + app.add_handler(CommandHandler('testall', testall_cmd)) + app.add_handler(CommandHandler('exportconfig', export_config_cmd)) + app.add_handler(CommandHandler('info', info_cmd)) + app.add_handler(CommandHandler('jobs', jobs_cmd)) + app.add_handler(CommandHandler('history', history_cmd)) + app.add_handler(CommandHandler('ip', ip_cmd)) + app.add_handler(CommandHandler('nexttrace', nexttrace_cmd)) + app.add_handler(CallbackQueryHandler(on_button)) + app.add_handler(MessageHandler(filters.Document.ALL, document_handler)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, fallback_panel)) + app.add_error_handler(error_handler) + print(f'guko telegram bot started v{GUKO_VERSION}', flush=True) + app.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == '__main__': + main() diff --git a/telegram-bot/render_checkplace.py b/telegram-bot/render_checkplace.py new file mode 100755 index 0000000..5b6b2bf --- /dev/null +++ b/telegram-bot/render_checkplace.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Render Check.Place/IPQuality SVG reports into Telegram-friendly PNG images. + +This intentionally does not rely on browser/SVG font metrics. Check.Place SVGs use +terminal cells (ch/em) plus colored background rectangles; normal SVG converters +often misalign mixed CJK/Latin text. This script parses the SVG and renders it as a +native terminal-like screenshot with a fixed cell grid and CJK fallback. +""" +from __future__ import annotations + +import argparse +import html +import re +import unicodedata +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +DEFAULT_LATIN = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf" +DEFAULT_LATIN_ITALIC = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Oblique.ttf" +DEFAULT_CJK = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc" + +FG = { + "fa0": (0, 0, 0), + "fa1": (255, 112, 112), + "fa2": (100, 255, 116), + "fa3": (255, 232, 96), + "fa6": (96, 245, 245), + "fa7": (246, 246, 246), +} +BG = { + "ba1": (178, 22, 22), + "ba2": (14, 150, 28), + "ba3": (166, 146, 22), + "ba7": (225, 225, 225), +} +TERMINAL_BG = (8, 10, 14) +OUTPUT_SCALE = 1 + + +def cells(ch: str) -> int: + return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 + + +def is_cjk(ch: str) -> bool: + o = ord(ch) + return (0x2E80 <= o <= 0x9FFF) or (0xF900 <= o <= 0xFAFF) or (0xFF00 <= o <= 0xFFEF) + + +def parse_svg_size(svg: str) -> tuple[int, int]: + m = re.search(r']*width="([0-9.]+)ch"[^>]*height="([0-9.]+)em"', svg) + if not m: + return 74, 47 + return int(float(m.group(1))), int(float(m.group(2))) + + +def render(svg_path: Path, out_path: Path, *, cell_w: int, cell_h: int, font_size: int, pad: int) -> None: + svg = svg_path.read_text("utf-8", errors="ignore") + width_cells, height_cells = parse_svg_size(svg) + + latin = ImageFont.truetype(DEFAULT_LATIN, font_size) + latin_italic = ImageFont.truetype(DEFAULT_LATIN_ITALIC, font_size) + cjk = ImageFont.truetype(DEFAULT_CJK, font_size) + + scale = OUTPUT_SCALE + cell_w *= scale + cell_h *= scale + font_size *= scale + pad *= scale + + image = Image.new( + "RGB", + (pad * 2 + width_cells * cell_w, pad * 2 + height_cells * cell_h), + TERMINAL_BG, + ) + draw = ImageDraw.Draw(image) + + # Draw terminal background highlight blocks first, using the same cell metrics as text. + rect_re = re.compile( + r'(.*?)', re.S) + span_re = re.compile(r'(.*?)', re.S) + + for tm in text_re.finditer(svg): + y = float(tm.group(1)) + top = pad + y * cell_h - cell_h / 2 + col = 0 + for sp in span_re.finditer(tm.group(2)): + classes = (sp.group(1) or "").split() + text = html.unescape(re.sub(r"<.*?>", "", sp.group(2))).replace("\r", "") + color = FG["fa7"] + italic = "italic" in classes + underline = "underline" in classes + for cls in classes: + if cls in FG: + color = FG[cls] + for ch in text: + span = cells(ch) + x = pad + col * cell_w + font = cjk if is_cjk(ch) else (latin_italic if italic else latin) + bbox = draw.textbbox((0, 0), ch, font=font) + text_w = bbox[2] - bbox[0] + text_h = bbox[3] - bbox[1] + tx = x + (span * cell_w - text_w) / 2 - bbox[0] + ty = top + (cell_h - text_h) / 2 - bbox[1] + draw.text((tx, ty), ch, font=font, fill=color) + if underline and ch != " ": + draw.line((x, top + cell_h - 3, x + span * cell_w, top + cell_h - 3), fill=color, width=1) + col += span + + out_path.parent.mkdir(parents=True, exist_ok=True) + image.save(out_path, optimize=False, compress_level=4) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render Check.Place SVG to terminal-like PNG") + parser.add_argument("svg", type=Path) + parser.add_argument("output", type=Path) + parser.add_argument("--cell-w", type=int, default=15, help="terminal cell width in px; wider cells make Telegram previews easier to read") + parser.add_argument("--cell-h", type=int, default=30, help="terminal cell height in px; taller rows keep larger text crisp") + parser.add_argument("--font-size", type=int, default=26, help="font size in px; tuned for readable Telegram previews") + parser.add_argument("--pad", type=int, default=10, help="padding in px") + args = parser.parse_args() + render(args.svg, args.output, cell_w=args.cell_w, cell_h=args.cell_h, font_size=args.font_size, pad=args.pad) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/telegram-bot/requirements.txt b/telegram-bot/requirements.txt new file mode 100644 index 0000000..46102c6 --- /dev/null +++ b/telegram-bot/requirements.txt @@ -0,0 +1,3 @@ +python-telegram-bot==21.9 +Pillow==11.2.1 +CairoSVG==2.7.1 diff --git a/telegram-bot/tools/bgp_fetch.py b/telegram-bot/tools/bgp_fetch.py new file mode 100755 index 0000000..68bd7b4 --- /dev/null +++ b/telegram-bot/tools/bgp_fetch.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +import argparse, gzip, ipaddress, re, sys, time, socket, zlib +from pathlib import Path +from urllib.request import Request, urlopen +from urllib.error import HTTPError, URLError +from html.parser import HTMLParser + +OUTDIR = Path('/data/media/bgp') +HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Encoding': 'gzip, deflate', + 'Referer': 'https://bgp.tools/', + 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7', + 'Upgrade-Insecure-Requests': '1', + 'Cache-Control': 'max-age=0', + 'Sec-Ch-Ua': '"Chromium";v="122", "Google Chrome";v="122", "Not=A?Brand";v="99"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Windows"', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Sec-Fetch-Dest': 'document', + 'Dnt': '1', + 'Sec-Gpc': '1', + 'Pragma': 'no-cache', +} + +class TextParser(HTMLParser): + def __init__(self): + super().__init__() + self.parts=[] + def handle_data(self, data): + if data and data.strip(): self.parts.append(data.strip()) + def text(self): return '\n'.join(self.parts) + +def resolve_target(s): + raw = s.strip() + m = re.search(r'(\d{1,3}(?:\.\d{1,3}){3})', raw) + if m: + try: + return ipaddress.IPv4Address(m.group(1)), None + except Exception: + raise SystemExit('ERROR: invalid IPv4') + + # Treat as domain/hostname: strip scheme/path/port and resolve A records. + host = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', raw).split('/')[0].split('?')[0].strip('[]') + if '@' in host: + host = host.rsplit('@', 1)[-1] + if ':' in host and host.count(':') == 1: + host = host.rsplit(':', 1)[0] + host = host.strip().rstrip('.') + if not host or not re.match(r'^[A-Za-z0-9.-]+$', host): + raise SystemExit('ERROR: no IPv4 or valid domain found') + + try: + infos = socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM) + except socket.gaierror as e: + raise SystemExit(f'ERROR: failed to resolve domain {host}: {e}') + ips = [] + for info in infos: + addr = info[4][0] + if addr not in ips: + ips.append(addr) + if not ips: + raise SystemExit(f'ERROR: no IPv4 A record found for {host}') + return ipaddress.IPv4Address(ips[0]), host + +def prefixes(ip): + # Prefer real visible prefixes from bgp.tools search, sorted by highest visibility. + # Tie-breaker: more-specific first, then bgp.tools row order. Blind /24 can be wrong. + real = search_prefixes(ip) + if real: + return [net for net, _visibility, _asn in real] + p24 = ipaddress.IPv4Network(f'{ip}/24', strict=False) + p23 = ipaddress.IPv4Network(f'{ip}/23', strict=False) + res=[p24] + if p23 != p24: res.append(p23) + return res + + +def search_prefixes(ip): + url=f'https://bgp.tools/search?q={ip}' + try: + html,_=fetch(url) + except Exception: + return [] + rows=[] + text=html.decode('utf-8','ignore') + visibility_rank={'high': 3, 'medium': 2, 'low': 1} + seen=set() + for rm in re.finditer(r']*>(.*?)', text, re.I | re.S): + row=rm.group(1) + pm=re.search(r'/prefix/(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})', row) + if not pm: + continue + try: + net=ipaddress.IPv4Network(pm.group(1), strict=False) + except Exception: + continue + if ip not in net or net in seen: + continue + seen.add(net) + am=re.search(r'/as/(\d+)', row, re.I) + asn=f'AS{am.group(1)}' if am else '' + cells=re.findall(r']*>(.*?)', row, re.I | re.S) + cell_text=[re.sub(r'<[^>]+>', ' ', c).strip() for c in cells] + visibility='' + for c in reversed(cell_text): + lc=re.sub(r'\s+', ' ', c).strip().lower() + if lc in visibility_rank: + visibility=lc + break + rows.append((net, visibility, asn, visibility_rank.get(visibility, 0), len(rows))) + # bgp.tools search may show route objects (e.g. RADB) without /prefix links or + # visibility cells. Include those containing prefixes so /21-/16 announcements + # are not missed when the visibility table is absent/noisy. + for m in re.finditer(r'\b(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})\b', text): + try: + net=ipaddress.IPv4Network(m.group(1), strict=False) + except Exception: + continue + if ip not in net or net in seen: + continue + seen.add(net) + # Unknown visibility ranks below explicit High/Medium/Low rows, but above blind fallback. + rows.append((net, 'unknown', '', 0, len(rows))) + rows.sort(key=lambda x: (x[3], x[0].prefixlen, -x[4]), reverse=True) + return [(net, visibility, asn) for net, visibility, asn, _rank, _idx in rows] + +def fetch(url, timeout=20): + req=Request(url, headers=HEADERS) + with urlopen(req, timeout=timeout) as r: + data = r.read() + enc = (r.headers.get('content-encoding') or '').lower() + if enc == 'gzip': + data = gzip.decompress(data) + elif enc == 'deflate': + try: + data = zlib.decompress(data) + except zlib.error: + data = zlib.decompress(data, -zlib.MAX_WBITS) + return data, r.headers.get('content-type','') + +def placeholder(svg: bytes): + txt = svg[:20000].decode('utf-8', 'ignore') + return 'Not_Visible' in txt and 'in_DFZ' in txt + +def svg_to_png(svg_path: Path, png_path: Path): + # Prefer cairosvg if present, fallback to rsvg-convert, then ImageMagick. + try: + import cairosvg + cairosvg.svg2png(url=str(svg_path), write_to=str(png_path), output_width=2400) + return + except Exception as e: + last=e + import subprocess, shutil + if shutil.which('rsvg-convert'): + subprocess.check_call(['rsvg-convert','-w','2400','-f','png','-o',str(png_path),str(svg_path)]) + return + if shutil.which('magick'): + subprocess.check_call(['magick','-density','300',str(svg_path),'-resize','2400x1800>',str(png_path)]) + return + raise RuntimeError(f'no SVG converter available; install cairosvg/sharp/librsvg/imagemagick. last={last}') + +def fetch_bgp(ip, domain=None, outdir=OUTDIR): + outdir.mkdir(parents=True, exist_ok=True) + tried=[]; ph=None + for net in prefixes(ip): + pfx=str(net) + urlip=pfx.replace('/','_') + url=f'https://bgp.tools/pathimg/rt-{urlip}?4c1db184-e649-4491-8b7f-06177bcb4f25&loggedin' + tried.append(url) + try: + data, ctype = fetch(url) + except HTTPError as e: + if e.code == 404: continue + continue + except URLError: + continue + if placeholder(data): + ph=pfx; continue + stamp=int(time.time()) + base=f'bgp-{str(net).replace("/","_")}-{stamp}' + svg=outdir/(base+'.svg') + png=outdir/(base+'.png') + target_safe=re.sub(r'[^A-Za-z0-9_.-]+', '_', str(domain or ip)).strip('_') or 'target' + latest=outdir/(f'latest-{target_safe}.png') + svg.write_bytes(data) + svg_to_png(svg, png) + latest.write_bytes(png.read_bytes()) + try: svg.unlink() + except Exception: pass + print(f'OK\nTARGET={domain or ip}\nIP={ip}\nPREFIX={pfx}\nPNG={png}\nLATEST={latest}\nURL=https://bgp.tools/prefix/{pfx}') + return 0 + if ph: + print(f'PLACEHOLDER\nTARGET={domain or ip}\nIP={ip}\nPREFIX={ph}\nURL=https://bgp.tools/prefix/{ph}\nREASON=bgp.tools temporarily returned no path image; please retry once') + return 2 + pfx=str(prefixes(ip)[0]) + print(f'NONE\nTARGET={domain or ip}\nIP={ip}\nPREFIX={pfx}\nURL=https://bgp.tools/prefix/{pfx}\nREASON=no usable BGP path image found') + return 3 + +def tld(domain): + parts=domain.split('.') + return '.'.join(parts[-2:]) if len(parts)>=2 else domain + +def fetch_dns(ip, domain=None): + for net in prefixes(ip): + pfx=str(net) + url=f'https://bgp.tools/prefix/{pfx}#dns' + try: + html,_=fetch(url) + except Exception: + continue + parser=TextParser(); parser.feed(html.decode('utf-8','ignore')) + text=parser.text() + rows=re.findall(r'(\d{1,3}(?:\.\d{1,3}){3})\s+([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', text) + counts={} + for _,d in rows: counts[tld(d)]=counts.get(tld(d),0)+1 + lines=[f'{a}\t{d}' for a,d in rows if counts.get(tld(d),0)<=2] + if lines: + print('OK_DNS') + print(f'TARGET={domain or ip}\nIP={ip}\nPREFIX={pfx}\nURL={url}') + print('DNS_LINES_BEGIN') + print('\n'.join(lines[:80])) + print('DNS_LINES_END') + return 0 + print(f'NONE_DNS\nTARGET={domain or ip}\nIP={ip}\nREASON=no DNS records found') + return 3 + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument('--dns', action='store_true') + ap.add_argument('--outdir', default=str(OUTDIR), help='directory for generated BGP images') + ap.add_argument('ip') + args=ap.parse_args() + ip, domain = resolve_target(args.ip) + return fetch_dns(ip, domain) if args.dns else fetch_bgp(ip, domain, Path(args.outdir)) + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/telegram-bot/tools/download_ippure.js b/telegram-bot/tools/download_ippure.js new file mode 100755 index 0000000..daf3d41 --- /dev/null +++ b/telegram-bot/tools/download_ippure.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +function arg(name, fallback = '') { + const i = process.argv.indexOf(`--${name}`); + if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1]; + return fallback; +} + +(async () => { + const ip = arg('ip') || process.argv[2]; + if (!ip) throw new Error('Usage: download_ippure.js --ip [--outdir ]'); + const outdir = arg('outdir', '/data/tmp/ippure-downloads'); + fs.mkdirSync(outdir, { recursive: true }); + + const candidates = [ + process.env.CHROMIUM_PATH, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + ].filter(Boolean); + const launchOptions = { headless: true, args: ['--no-sandbox'] }; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + launchOptions.executablePath = candidate; + break; + } + } + const browser = await chromium.launch(launchOptions); + const context = await browser.newContext({ + acceptDownloads: true, + viewport: { width: 1440, height: 1200 }, + deviceScaleFactor: 1, + locale: 'zh-CN', + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + }); + + const page = await context.newPage(); + await page.route('**/*', route => { + const url = route.request().url(); + if ( + url.includes('/cdn-cgi/rum') || + url.includes('/cdn-cgi/speculation') || + url.includes('cloudflareinsights.com') || + url.includes('/api/ads') || + url.includes('marker-icon.png') || + url.includes('marker-shadow.png') + ) return route.abort().catch(() => {}); + return route.continue().catch(() => {}); + }); + + const url = `https://ippure.com/?ip=${encodeURIComponent(ip)}`; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 }); + await page.waitForSelector('.iptable-container', { state: 'visible', timeout: 45000 }); + await page.waitForSelector('button.screenshot-btn svg.lucide-camera', { state: 'visible', timeout: 45000 }); + + // Faster than waiting for full network idle: wait only for the official card to be populated. + await page.waitForFunction((targetIp) => { + const card = document.querySelector('.iptable-container'); + const text = card?.innerText || ''; + return text.includes(targetIp) && text.includes('IPPure系数') && !text.includes('Loading...'); + }, ip, { timeout: 20000 }).catch(() => {}); + + // Linux headless Chrome doesn't have PingFang/SF Pro. After Playwright deps are installed, + // fallback font metrics can make the IPPure score badge wrap (e.g. "40%\n中性"). + // Keep the official camera export path, but stabilize fonts/nowrap inside the exported card. + await page.addStyleTag({ content: ` + .iptable-container, .iptable-container * { + font-family: "Noto Sans CJK SC", "Noto Sans SC", "Microsoft YaHei", "PingFang SC", Arial, sans-serif !important; + } + .iptable-container .font-mono { + font-family: "DejaVu Sans Mono", "Noto Sans Mono CJK SC", monospace !important; + } + .iptable-container .colormap-indicator-value { + white-space: nowrap !important; + min-width: max-content !important; + } + ` }).catch(() => {}); + + // Wait for web fonts/layout to settle so the export captures the stabilized layout. + await page.evaluate(async () => { + await document.fonts?.ready?.catch?.(() => {}); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }).catch(() => {}); + + // Hide/mask the queried IP before exporting the official PNG. IPPure exposes this as + // the eye button next to the camera button; click it first so downloaded images do not + // leak the full IP address. + const hideIpButton = page.locator('button.screenshot-btn').filter({ has: page.locator('svg.lucide-eye') }).first(); + if (await hideIpButton.count()) { + await hideIpButton.click({ timeout: 10000 }); + await page.waitForFunction((targetIp) => { + const card = document.querySelector('.iptable-container'); + const text = card?.innerText || ''; + return !text.includes(targetIp); + }, ip, { timeout: 5000 }).catch(() => {}); + await page.evaluate(async () => { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }).catch(() => {}); + } + + const cameraButton = page.locator('button.screenshot-btn').filter({ has: page.locator('svg.lucide-camera') }).first(); + const downloadPromise = page.waitForEvent('download', { timeout: 30000 }); + await cameraButton.click({ timeout: 10000 }); + const download = await downloadPromise; + const suggested = await download.suggestedFilename(); + const filename = suggested && suggested.toLowerCase().endsWith('.png') ? suggested : `IPPure-${ip}-${Date.now()}.png`; + const out = path.join(outdir, filename); + await download.saveAs(out); + await browser.close(); + console.log(out); +})().catch(err => { + console.error(err.stack || err.message || String(err)); + process.exit(1); +});