import upstream GUKO 2026-06-23
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
44
.env.example
Normal 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
25
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||
17
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
27
.github/workflows/ci.yml
vendored
Normal 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
45
.github/workflows/docker-publish.yml
vendored
Normal 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
66
.github/workflows/release.yml
vendored
Normal 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
14
.gitignore
vendored
Normal 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
122
CHANGELOG.md
Normal 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 Drafter;Docker 发布保留 latest、版本号和 sha 标签。
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
27
Makefile
Normal 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
430
README.en.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# GUKO
|
||||
|
||||
[](https://github.com/shuijiao1/GUKO/pkgs/container/guko)
|
||||
[](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
427
README.md
Normal file
@@ -0,0 +1,427 @@
|
||||
# GUKO
|
||||
|
||||
[](https://github.com/shuijiao1/GUKO/pkgs/container/guko)
|
||||
[](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
50
SECURITY.md
Normal 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
109
auth.py
Normal 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)
|
||||
14
docker-compose.example.yml
Normal file
14
docker-compose.example.yml
Normal 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
86
guko.py
Executable 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
16
install.sh
Executable 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
6
jiaoops.py
Executable 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
5
optional/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Optional Integrations
|
||||
|
||||
这里放可选集成,不属于 GUKO 默认功能。
|
||||
|
||||
- `update_from_nezha.py`:实验性 Nezha 面板同步脚本。开源默认不启用、不复制进 Docker 镜像。
|
||||
213
optional/update_from_nezha.py
Executable file
213
optional/update_from_nezha.py
Executable 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
96
release.sh
Executable 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
33
scripts/security-scan.sh
Executable 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
43
servers.example.json
Normal 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
21
telegram-bot/Dockerfile
Normal 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
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
145
telegram-bot/render_checkplace.py
Executable 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())
|
||||
3
telegram-bot/requirements.txt
Normal file
3
telegram-bot/requirements.txt
Normal 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
241
telegram-bot/tools/bgp_fetch.py
Executable 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())
|
||||
118
telegram-bot/tools/download_ippure.js
Executable file
118
telegram-bot/tools/download_ippure.js
Executable 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);
|
||||
});
|
||||
Reference in New Issue
Block a user