import upstream GUKO 2026-06-23

This commit is contained in:
OpenClaw
2026-06-23 00:44:35 +00:00
commit 0e79f74184
30 changed files with 6419 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.env
servers.json
servers.yml
docker-compose.yml
keys/
media/
tmp/
results/
backups/
__pycache__/
*.pyc
*.bak*

44
.env.example Normal file
View File

@@ -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

25
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -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

View File

@@ -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?

27
.github/workflows/ci.yml vendored Normal file
View File

@@ -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 .

45
.github/workflows/docker-publish.yml vendored Normal file
View File

@@ -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

66
.github/workflows/release.yml vendored Normal file
View File

@@ -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

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# secrets / runtime data
.env
servers.json
servers.yml
keys/
media/
tmp/
results/
*.bak*
__pycache__/
*.pyc
docker-compose.yml
history.json

122
CHANGELOG.md Normal file
View File

@@ -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 DrafterDocker 发布保留 latest、版本号和 sha 标签。

21
LICENSE Normal file
View File

@@ -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.

27
Makefile Normal file
View File

@@ -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)

430
README.en.md Normal file
View File

@@ -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 <name/IP/ID/alias>` — Test SSH for one server
- `/testall` — Batch test SSH
- `/exportconfig` — Export sanitized config
- `/info <name/IP/ID/alias>` — Show single-server details
- `/health` — Read-only health check
- `/jobs` — Show background jobs
- `/ip <IPv4 or domain>` — IPPure / BGP tools
- `/nexttrace <server> <target>` — 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 <install-dir>
docker compose ps
docker compose logs -f
docker compose restart
docker compose down
```
Upgrade:
```bash
cd <install-dir>
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
---

427
README.md Normal file
View File

@@ -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 <IPv4 或域名>` — 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

50
SECURITY.md Normal file
View File

@@ -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`

109
auth.py Normal file
View File

@@ -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)

View File

@@ -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

86
guko.py Executable file
View File

@@ -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 = '<no-host>'
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','?')} (<no-host>) ==")
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()

16
install.sh Executable file
View File

@@ -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

6
jiaoops.py Executable file
View File

@@ -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__')

5
optional/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Optional Integrations
这里放可选集成,不属于 GUKO 默认功能。
- `update_from_nezha.py`:实验性 Nezha 面板同步脚本。开源默认不启用、不复制进 Docker 镜像。

213
optional/update_from_nezha.py Executable file
View File

@@ -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 '<no-host>':<15} {s.get('last_active')}")
if __name__ == '__main__':
main()

96
release.sh Executable file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'HELP'
Usage: ./release.sh <version> "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"

33
scripts/security-scan.sh Executable file
View File

@@ -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"

43
servers.example.json Normal file
View File

@@ -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"
}
]
}

21
telegram-bot/Dockerfile Normal file
View File

@@ -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"]

3942
telegram-bot/bot.py Executable file

File diff suppressed because it is too large Load Diff

145
telegram-bot/render_checkplace.py Executable file
View File

@@ -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'<svg[^>]*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'<rect x="([0-9.]+)ch" y="([0-9.]+)em" width="([0-9.]+)ch" height="1em" class="(ba\d)"'
)
for m in rect_re.finditer(svg):
x, y, w, cls = float(m.group(1)), float(m.group(2)), float(m.group(3)), m.group(4)
color = BG.get(cls)
if not color:
continue
draw.rectangle(
[
pad + x * cell_w,
pad + y * cell_h,
pad + (x + w) * cell_w,
pad + (y + 1) * cell_h,
],
fill=color,
)
text_re = re.compile(r'<text x="0ch" y="([0-9.]+)em">(.*?)</text>', re.S)
span_re = re.compile(r'<tspan(?: class="([^"]+)")?>(.*?)</tspan>', 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())

View File

@@ -0,0 +1,3 @@
python-telegram-bot==21.9
Pillow==11.2.1
CairoSVG==2.7.1

241
telegram-bot/tools/bgp_fetch.py Executable file
View File

@@ -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'<tr\b[^>]*>(.*?)</tr>', 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'<td\b[^>]*>(.*?)</td>', 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())

View File

@@ -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 <IPv4> [--outdir <dir>]');
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);
});