Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb02b0c5bd | ||
|
|
e7365e3005 | ||
|
|
1b0f2646fe | ||
|
|
31d725ff84 | ||
|
|
21fa445ff8 | ||
|
|
e7cccfbd99 | ||
|
|
f12c97cf8b | ||
|
|
cc8690d264 | ||
|
|
b705dbdc18 | ||
|
|
25a8d7a651 | ||
|
|
f44da9a467 |
No files matched your search
@@ -21,6 +21,39 @@ curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/install.sh -o ins
|
||||
- [节点 API 与协议](docs/guide/node-api.md)(业务接口、管理接口、WS 协议、上报协议)
|
||||
- [公开 API(/api/v1)](docs/guide/public-api.md)(收集中心程序化接口:鉴权、端点与响应示例、公开状态接口)
|
||||
|
||||
## 贡献节点
|
||||
|
||||
把一台闲置的 Linux 机器(VPS、软路由、小主机)贡献为公共拨测节点:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/nomdn/ipw-cn/main/contribute.sh -o contribute.sh && sudo bash contribute.sh
|
||||
```
|
||||
|
||||
脚本静默完成全部步骤,中途无需任何交互:
|
||||
|
||||
- 自动挑选一个空闲高位端口(20000-65000),节点标识(节点 id / 注册 key / access-token)随机生成
|
||||
- 默认以 WS 方式**同时接入官方两个中间件**(多活,节点主动出站连接,无需公网放行端口)
|
||||
- 安装到 `/opt/lemon-ipw`,注册为 systemd 服务 `lemon-ipw` 并设置开机自启
|
||||
- 结束时打印节点信息(同时备份在 `/opt/lemon-ipw/node-info.txt`)
|
||||
|
||||
把打印出来的信息发给管理员登记,节点即可上线(联系方式见文末)。
|
||||
|
||||
| 选项 | 说明 |
|
||||
| --- | --- |
|
||||
| `--ipdb` | 启用 IP 数据库(首次启动后台下载约 450MB,默认关闭) |
|
||||
| `--port <n>` | 指定监听端口(默认随机空闲端口) |
|
||||
| `--ws-url <url>` | 改为接入自建中间件,逗号分隔可填多个(默认官方两个) |
|
||||
| `--version <tag>` | 指定版本(默认取 GitHub 最新 release) |
|
||||
| `--gh-proxy <p>` | GitHub 下载加速前缀,如 `https://ghfast.top/` |
|
||||
| `--dry-run` | 只检查环境并打印信息,不下载、不写 systemd |
|
||||
|
||||
日常运维:
|
||||
|
||||
```bash
|
||||
systemctl status lemon-ipw # 查看状态
|
||||
journalctl -u lemon-ipw -f # 跟踪日志
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
@@ -30,7 +63,8 @@ ipw-cn/
|
||||
├── serverless/ # 独立组件 边缘/Serverless(edgeone、edgeone-getip、lemon-getip)
|
||||
├── frontend-ssr/ # Nuxt 4 SSR 前端
|
||||
├── docs/ # 文档站
|
||||
└── install.sh # 一键安装脚本
|
||||
├── install.sh # 一键安装脚本(自建完整部署)
|
||||
└── contribute.sh # 贡献节点脚本(一台闲置机器接为公共节点)
|
||||
```
|
||||
## 友情链接
|
||||
### 来自社区的IPW版本
|
||||
@@ -49,6 +83,12 @@ ipw-cn/
|
||||
| DNS 查询 | miekg/dns(DoH / UDP 双通道 + 主从 failover) |
|
||||
| 部署 | Docker、Cloudflare Workers、EdgeOne Pages、systemd |
|
||||
|
||||
## 联系方式
|
||||
|
||||
- 邮箱:[iduhih777@outlook.com](mailto:iduhih777@outlook.com)
|
||||
- GitHub:[@nomdn](https://github.com/nomdn)
|
||||
- 站点:[nipw.cn](https://nipw.cn) | 拨测控制台:[boce.wsmdn.top](https://boce.wsmdn.top)
|
||||
|
||||
## 许可证
|
||||
|
||||
[GPL-3.0](LICENSE)
|
||||
+541
@@ -0,0 +1,541 @@
|
||||
#!/usr/bin/bash
|
||||
# ============================================================
|
||||
# 节点贡献脚本(静默安装)
|
||||
#
|
||||
# 用途:把一台闲置 Linux 机器贡献为拨测节点。
|
||||
# - 全程无交互:端口自动挑一个空闲高位端口,节点标识随机生成
|
||||
# - 命名与官方 install.sh 保持一致:二进制 lemonipw / 目录 /opt/lemon-ipw / 服务 lemon-ipw
|
||||
# - 默认同时接入柠檬的两个中间件 WS 通道(多活,节点主动出站,无需公网放行端口)
|
||||
# - 安装完成后打印节点信息,提交给管理员登记即可上线
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash contribute.sh # 全默认:系统级安装 + 自动端口 + 接 WS
|
||||
# sudo bash contribute.sh --ipdb # 启用 IP 数据库(首启后台下载 ~450MB)
|
||||
# sudo bash contribute.sh --port 34567 # 指定端口
|
||||
# bash contribute.sh # 非 root:自动装成 systemd --user 用户级服务(不询问)
|
||||
# bash contribute.sh --user # 显式指定用户级(与上面的自动降级等价)
|
||||
# sudo bash contribute.sh --dry-run # 只生成并打印信息,不下载、不落盘
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 默认同时接入的两个中间件(逗号分隔 = 节点侧同时连接全部、多活;
|
||||
# 任一断开只重连自己,不影响另一条连接)。顺序无关,收集中心放前面便于阅读。
|
||||
WS_URL_DEFAULT="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
CONTACT_EMAIL="iduhih777@outlook.com"
|
||||
PORT_MIN=20000
|
||||
PORT_MAX=65000
|
||||
|
||||
OPT_PORT=""
|
||||
OPT_WS_URL=""
|
||||
OPT_VERSION=""
|
||||
OPT_GH_PROXY=""
|
||||
OPT_IPDB="false"
|
||||
OPT_SCOPE=""
|
||||
DRY_RUN="false"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法: sudo bash contribute.sh [选项]
|
||||
|
||||
--port <n> 监听端口(默认:20000-65000 中的随机空闲端口)
|
||||
--ws-url <url> 中间件 WS 地址,逗号分隔可多个(默认:柠檬两个中间件,同时连接)
|
||||
--version <tag> 指定版本(默认:取 GitHub 最新 release)
|
||||
--gh-proxy <p> GitHub 下载加速前缀,如 https://ghfast.top/
|
||||
--ipdb 启用 IP 数据库(首次启动后台下载,约 450MB;默认关闭)
|
||||
--user 用户级安装(systemd --user,unit 写 ~/.config/systemd/user,无需 root)
|
||||
--system 系统级安装(unit 写 /etc/systemd/system,需 root;非 root 下指定会直接报错)
|
||||
--dry-run 只做检查与信息生成,不下载、不写 systemd
|
||||
-h, --help 显示本帮助
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- 参数解析 ----------
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
key="$1"
|
||||
case "$key" in
|
||||
--ipdb) OPT_IPDB="true"; shift; continue ;;
|
||||
--user) OPT_SCOPE="user"; shift; continue ;;
|
||||
--system) OPT_SCOPE="system"; shift; continue ;;
|
||||
--dry-run) DRY_RUN="true"; shift; continue ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--port|--ws-url|--version|--gh-proxy) ;;
|
||||
*) echo "错误:未知参数 $key(用 --help 查看用法)" >&2; exit 1 ;;
|
||||
esac
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "错误:$key 缺少参数值(用 --help 查看用法)" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$key" in
|
||||
--port) OPT_PORT="$2" ;;
|
||||
--ws-url) OPT_WS_URL="$2" ;;
|
||||
--version) OPT_VERSION="$2" ;;
|
||||
--gh-proxy) OPT_GH_PROXY="$2" ;;
|
||||
esac
|
||||
shift 2
|
||||
done
|
||||
|
||||
# ---------- 随机值生成 ----------
|
||||
|
||||
# 生成 UUID(优先 /proc,其次 uuidgen,最后 /dev/urandom)
|
||||
gen_uuid() {
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
cat /proc/sys/kernel/random/uuid
|
||||
elif command -v uuidgen >/dev/null 2>&1; then
|
||||
uuidgen | tr 'A-Z' 'a-z'
|
||||
else
|
||||
od -An -N16 -tx1 /dev/urandom | tr -d ' \n' \
|
||||
| sed 's/\(........\)\(....\)\(....\)\(....\)\(............\)/\1-\2-\3-\4-\5/'
|
||||
fi
|
||||
}
|
||||
|
||||
# 32 位十六进制令牌(去横线的 UUID)
|
||||
gen_token() { gen_uuid | tr -d '-'; }
|
||||
|
||||
# ---------- 端口 ----------
|
||||
|
||||
# 列出本机监听端口(ss 优先,netstat 次之)
|
||||
listening_ports() {
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -ltn 2>/dev/null | awk 'NR>1 {print $4}'
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
netstat -ltn 2>/dev/null | awk 'NR>2 {print $4}'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 0 = 端口已被占用,1 = 空闲
|
||||
port_in_use() {
|
||||
local p="$1" ports
|
||||
ports=$(listening_ports || true)
|
||||
if [ -n "$ports" ]; then
|
||||
if printf '%s\n' "$ports" | sed 's/.*[.:]//' | grep -qx "$p"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
# 兜底:连得上即视为被占用(无 ss / netstat 的极简系统)
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then
|
||||
exec 3>&-
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 随机高位端口(20000-65000),连续试探直到落到空闲端口
|
||||
pick_port() {
|
||||
local i p
|
||||
for i in $(seq 1 50); do
|
||||
p=$(( (RANDOM * 32768 + RANDOM) % (PORT_MAX - PORT_MIN + 1) + PORT_MIN ))
|
||||
if ! port_in_use "$p"; then
|
||||
printf '%s' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "错误:连续 50 次未找到空闲的高位端口,请用 --port 指定" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------- 权限与 systemd 作用域 ----------
|
||||
|
||||
# system = 系统级:写 /etc/systemd/system,开机自启走 multi-user.target(需 root)
|
||||
# user = 用户级:写 ~/.config/systemd/user,开机自启走 default.target(systemd --user)
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
case "$OPT_SCOPE" in
|
||||
system)
|
||||
# dry-run 只在本地打印信息,不落盘,不必真有 root
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
echo "错误:--system 需要 root 权限(写 /etc/systemd/system),请用 sudo 运行或改用 --user" >&2
|
||||
exit 1
|
||||
fi
|
||||
SYSTEMD_MODE="system"
|
||||
;;
|
||||
user)
|
||||
SYSTEMD_MODE="user"
|
||||
;;
|
||||
*)
|
||||
# 非 root 且未指定作用域:本脚本定位是"静默安装",直接降为用户级,不打断询问
|
||||
SYSTEMD_MODE="user"
|
||||
echo "提示:当前不是 root 环境,自动改用 systemd --user(用户级服务,仅对当前用户生效)。" >&2
|
||||
echo " 如需系统级安装,请用 sudo 重新运行。" >&2
|
||||
;;
|
||||
esac
|
||||
else
|
||||
SYSTEMD_MODE="system"
|
||||
if [ "$OPT_SCOPE" = "user" ]; then
|
||||
SYSTEMD_MODE="user"
|
||||
echo "提示:以 root 身份使用 --user,unit 会写到 root 的用户级目录(~/.config/systemd/user)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 环境检查 ----------
|
||||
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 用户级实例依赖登录会话与 DBus,容器 / cron 等环境常常拿不到,提前拦掉
|
||||
if [ "$SYSTEMD_MODE" = "user" ] && ! systemctl --user list-units >/dev/null 2>&1; then
|
||||
echo "错误:当前环境无法使用 systemd --user(无用户级 systemd 实例或缺少 DBus 会话)" >&2
|
||||
echo " 可改用 sudo bash contribute.sh 做系统级安装" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
|
||||
echo "错误:未找到 wget 或 curl,无法下载" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 运维命令按作用域切换(用户级必须带 --user,否则查的是系统级命名空间)
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
CTL_CMD="systemctl --user"
|
||||
JRN_CMD="journalctl --user -u"
|
||||
SCOPE_LABEL="用户级 systemd --user"
|
||||
else
|
||||
CTL_CMD="systemctl"
|
||||
JRN_CMD="journalctl -u"
|
||||
SCOPE_LABEL="系统级 systemd"
|
||||
fi
|
||||
sc() { if [ "$SYSTEMD_MODE" = "user" ]; then systemctl --user "$@"; else systemctl "$@"; fi; }
|
||||
jc() { if [ "$SYSTEMD_MODE" = "user" ]; then journalctl --user "$@"; else journalctl "$@"; fi; }
|
||||
|
||||
# ---------- 架构检测 ----------
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH_TAG="amd64" ;;
|
||||
i386|i486|i586|i686) ARCH_TAG="386" ;;
|
||||
aarch64|arm64) ARCH_TAG="arm64" ;;
|
||||
armv7l|armv7hl) ARCH_TAG="armv7" ;;
|
||||
armv6l) ARCH_TAG="armv6" ;;
|
||||
loongarch64) ARCH_TAG="loong64" ;;
|
||||
*)
|
||||
echo "错误:不支持的架构 '$(uname -m)'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
RELEASE_ASSET="lemonipw-linux-${ARCH_TAG}"
|
||||
|
||||
# ---------- 命名(与 install.sh 保持一致) ----------
|
||||
|
||||
# 二进制 / 安装目录 / 服务名统一固定为 lemon 系列,便于运维识别与排查;
|
||||
# 一台机器只跑一份,重跑本脚本 = 覆盖同名服务与目录。
|
||||
BIN_BASE="lemonipw"
|
||||
SERVICE_NAME="lemon-ipw"
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lemon-ipw"
|
||||
SERVICE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service"
|
||||
UNIT_USER_LINE=""
|
||||
UNIT_NETWORK_LINES=""
|
||||
UNIT_WANTED_BY="default.target"
|
||||
else
|
||||
INSTALL_DIR="/opt/lemon-ipw"
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
# 系统级 unit 的 User= 行与 network-online 依赖;用户级没有这两个概念
|
||||
UNIT_USER_LINE="User=root
|
||||
"
|
||||
UNIT_NETWORK_LINES="After=network-online.target
|
||||
Wants=network-online.target
|
||||
"
|
||||
UNIT_WANTED_BY="multi-user.target"
|
||||
fi
|
||||
|
||||
# 同名服务已存在(例如这台机器已经跑着一个节点):再跑一次会用新生成的
|
||||
# 节点标识覆盖旧配置,旧节点在收集中心的记录就失效了,先提醒一句。
|
||||
if [ "$DRY_RUN" != "true" ] && [ -f "$SERVICE_FILE" ]; then
|
||||
echo "提示:已存在服务 ${SERVICE_NAME},本次会覆盖其配置并生成新的节点标识(旧标识在收集中心将失效)" >&2
|
||||
fi
|
||||
|
||||
PORTS="${OPT_PORT:-$(pick_port)}"
|
||||
case "$PORTS" in
|
||||
""|*[!0-9]*)
|
||||
echo "错误:端口必须是数字(当前值:$PORTS)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 10# 前缀强制十进制:避免 "0800" 这类带前导 0 的值被当成八进制解析
|
||||
PORTS=$((10#$PORTS))
|
||||
if [ "$PORTS" -lt 1 ] || [ "$PORTS" -gt 65535 ]; then
|
||||
echo "错误:端口超出范围 1-65535(当前值:$PORTS)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$OPT_PORT" ] && port_in_use "$PORTS"; then
|
||||
echo "警告:端口 $PORTS 当前已被占用,节点可能启动失败" >&2
|
||||
fi
|
||||
|
||||
WS_URL="${OPT_WS_URL:-$WS_URL_DEFAULT}"
|
||||
# 逐段校验(逗号分隔可多个),并去掉段内空格:节点侧虽会 trim,
|
||||
# 但空格会原样进 systemd 的 Environment 值,还是在这里清掉更干净。
|
||||
WS_URL_SEGMENTS=""
|
||||
_old_ifs="$IFS"; IFS=','
|
||||
for _seg in $WS_URL; do
|
||||
# 去前导/尾随空白(IFS 只含逗号,空白不会被自动去掉)
|
||||
_seg="${_seg#"${_seg%%[![:space:]]*}"}"
|
||||
_seg="${_seg%"${_seg##*[![:space:]]}"}"
|
||||
[ -n "$_seg" ] || continue
|
||||
case "$_seg" in
|
||||
ws://*|wss://*) ;;
|
||||
*)
|
||||
IFS="$_old_ifs"
|
||||
echo "错误:WS 地址的每一段都需以 ws:// 或 wss:// 开头(问题段:${_seg})" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$_seg" in
|
||||
*/ws|*/ws/) ;;
|
||||
*) echo "警告:WS 地址 ${_seg} 看起来不含 /ws 路径,节点可能连不上(节点默认连 <地址>/ws)" >&2 ;;
|
||||
esac
|
||||
WS_URL_SEGMENTS="${WS_URL_SEGMENTS:+${WS_URL_SEGMENTS},}${_seg}"
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
if [ -z "$WS_URL_SEGMENTS" ]; then
|
||||
echo "错误:WS 地址为空(当前值:${WS_URL})" >&2
|
||||
exit 1
|
||||
fi
|
||||
WS_URL="$WS_URL_SEGMENTS"
|
||||
|
||||
NODE_ID=$(gen_uuid)
|
||||
NODE_KEY=$(gen_token)
|
||||
ACCESS_TOKEN=$(gen_token)
|
||||
|
||||
# ---------- 公网地址探测(失败不阻断) ----------
|
||||
|
||||
detect_ip() {
|
||||
local url="$1" out=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
out=$(curl -fsS -m 5 "$url" 2>/dev/null || true)
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
out=$(wget -qO- --timeout=5 "$url" 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s' "$out" | tr -d ' \t\r\n'
|
||||
}
|
||||
|
||||
is_ipv4() { printf '%s' "$1" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; }
|
||||
is_ipv6() {
|
||||
printf '%s' "$1" | grep -qE '^[0-9A-Fa-f:]+$' || return 1
|
||||
printf '%s' "$1" | grep -q ':' || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
IPV4=$(detect_ip "https://4.wsmdn.top/")
|
||||
is_ipv4 "$IPV4" || IPV4=""
|
||||
IPV6=$(detect_ip "https://6.wsmdn.top/")
|
||||
is_ipv6 "$IPV6" || IPV6=""
|
||||
|
||||
# 单栈判定:只有一侧能出网就按单栈跑,避免另一协议的拨测全失败刷错误日志
|
||||
SINGLE_STACK=""
|
||||
STACK_LABEL="双栈"
|
||||
if [ -n "$IPV4" ] && [ -z "$IPV6" ]; then
|
||||
SINGLE_STACK="ipv4"
|
||||
STACK_LABEL="IPv4"
|
||||
elif [ -z "$IPV4" ] && [ -n "$IPV6" ]; then
|
||||
SINGLE_STACK="ipv6"
|
||||
STACK_LABEL="IPv6"
|
||||
fi
|
||||
|
||||
# ---------- 版本与下载地址 ----------
|
||||
|
||||
fetch_latest_version() {
|
||||
local api="https://api.github.com/repos/nomdn/ipw-cn/releases/latest" raw=""
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
raw=$(curl -fsS -m 20 "$api" 2>/dev/null || true)
|
||||
else
|
||||
raw=$(wget -qO- --timeout=20 "$api" 2>/dev/null || true)
|
||||
fi
|
||||
printf '%s' "$raw" | grep -o '"tag_name": *"[^"]*"' | head -1 | cut -d'"' -f4
|
||||
}
|
||||
|
||||
VERSION="$OPT_VERSION"
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(fetch_latest_version || true)
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "错误:无法获取最新版本号(GitHub API 不可达或限流)。可用 --version <tag> 指定,例如 --version v3.7.0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://github.com/nomdn/ipw-cn/releases/download/${VERSION}/${RELEASE_ASSET}"
|
||||
if [ -n "$OPT_GH_PROXY" ]; then
|
||||
DOWNLOAD_URL="${OPT_GH_PROXY%/}/${DOWNLOAD_URL}"
|
||||
fi
|
||||
|
||||
# ---------- 输出节点信息 ----------
|
||||
|
||||
print_info() {
|
||||
local dry_note=""
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
dry_note="(dry-run,未实际安装)"
|
||||
fi
|
||||
local ipv4_disp="${IPV4:-检测失败}"
|
||||
local ipv6_disp="${IPV6:-检测失败}"
|
||||
local ipdb_disp="未启用(仅影响 IP 归属地/ASN 类拨测,需要时用 --ipdb 重装启用)"
|
||||
if [ "$OPT_IPDB" = "true" ]; then
|
||||
ipdb_disp="已启用(首次启动后台下载,约 450MB)"
|
||||
fi
|
||||
# WS 地址逐个列出(可能接多个中间件),首个带标签、其余对齐缩进
|
||||
local ws_block=""
|
||||
local _first="true" _u=""
|
||||
local _old_ifs="$IFS"; IFS=','
|
||||
for _u in $WS_URL; do
|
||||
if [ "$_first" = "true" ]; then
|
||||
ws_block=" WS 地址: ${_u}"
|
||||
_first="false"
|
||||
else
|
||||
ws_block="${ws_block}
|
||||
${_u}"
|
||||
fi
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
cat << EOF
|
||||
|
||||
========================================
|
||||
贡献节点${dry_note}已就绪,请把以下信息提交给管理员
|
||||
========================================
|
||||
节点 id: ${NODE_ID}
|
||||
注册 key: ${NODE_KEY}
|
||||
${ws_block}
|
||||
监听端口: ${PORTS}
|
||||
单栈模式: ${STACK_LABEL}
|
||||
access-token: ${ACCESS_TOKEN}
|
||||
CORS: 不限
|
||||
公网 IPv4: ${ipv4_disp}
|
||||
公网 IPv6: ${ipv6_disp}
|
||||
地区-运营商: 请自行填写
|
||||
----------------------------------------
|
||||
中间件 ws-keys(**每个 WS 地址对应的中间件都要登记**,缺一个该条连接会被拒 401):
|
||||
"${NODE_ID}": "${NODE_KEY}"
|
||||
中心 / 中间件 api-keys(HTTP 直连本节点时的鉴权):
|
||||
"${NODE_ID}": "${ACCESS_TOKEN}"
|
||||
----------------------------------------
|
||||
提交邮箱: ${CONTACT_EMAIL}
|
||||
说明:
|
||||
1. 本节点默认以 WS 方式同时接入上面的中间件(节点主动出站连接),无需公网放行 ${PORTS}/TCP。
|
||||
2. 若接收方希望以 HTTP 方式直连本节点,需放行 ${PORTS}/TCP 且该端口公网可达。
|
||||
3. IP 数据库: ${ipdb_disp}
|
||||
----------------------------------------
|
||||
运维命令(${SCOPE_LABEL}):
|
||||
状态: ${CTL_CMD} status ${SERVICE_NAME}
|
||||
日志: ${JRN_CMD} ${SERVICE_NAME} -f
|
||||
重启: ${CTL_CMD} restart ${SERVICE_NAME}
|
||||
卸载: ${CTL_CMD} disable --now ${SERVICE_NAME} && rm -f ${SERVICE_FILE} && rm -rf ${INSTALL_DIR}
|
||||
节点信息备份: ${INSTALL_DIR}/node-info.txt
|
||||
========================================
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---------- dry-run:只展示 ----------
|
||||
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "架构: $(uname -m) → ${RELEASE_ASSET}"
|
||||
echo "版本: ${VERSION}"
|
||||
echo "下载地址: ${DOWNLOAD_URL}"
|
||||
echo "安装目录: ${INSTALL_DIR}"
|
||||
echo "服务名: ${SERVICE_NAME}"
|
||||
echo "作用域: ${SCOPE_LABEL}"
|
||||
echo "随机: 端口=${PORTS} 节点id=${NODE_ID}"
|
||||
print_info
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- 下载二进制 ----------
|
||||
|
||||
echo "正在下载 ${VERSION} / ${RELEASE_ASSET} ..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
if command -v wget >/dev/null 2>&1; then
|
||||
wget -q -O "${INSTALL_DIR}/${BIN_BASE}" "$DOWNLOAD_URL"
|
||||
else
|
||||
# -f 让 4xx 直接失败,避免把 GitHub 的 Not Found 页面存成二进制
|
||||
curl -fsSL -o "${INSTALL_DIR}/${BIN_BASE}" "$DOWNLOAD_URL"
|
||||
fi
|
||||
chmod +x "${INSTALL_DIR}/${BIN_BASE}"
|
||||
|
||||
# ---------- 生成 systemd 服务 ----------
|
||||
|
||||
# 仅非空项写入,避免空值覆盖节点自身默认值。
|
||||
# 值内的双引号 / 反斜杠 / % 必须转义:前两者会破坏 systemd 的引号包裹,
|
||||
# % 是 unit 说明符(要写成 %% 才是字面量)。
|
||||
ENV_LINES=""
|
||||
add_env() {
|
||||
local name="$1" value="$2"
|
||||
[ -n "$value" ] || return 0
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//%/%%}"
|
||||
ENV_LINES="${ENV_LINES}Environment=\"${name}=${value}\"
|
||||
"
|
||||
}
|
||||
add_env PORTS "$PORTS"
|
||||
add_env SINGLE_STACK "$SINGLE_STACK"
|
||||
add_env WS_URL "$WS_URL"
|
||||
add_env NODE_ID "$NODE_ID"
|
||||
add_env NODE_KEY "$NODE_KEY"
|
||||
add_env ACCESS_TOKEN "$ACCESS_TOKEN"
|
||||
add_env IPDB "$OPT_IPDB"
|
||||
|
||||
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
|
||||
mkdir -p "$(dirname "$SERVICE_FILE")"
|
||||
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=Lemon IPW Backend Node
|
||||
${UNIT_NETWORK_LINES}
|
||||
[Service]
|
||||
Type=simple
|
||||
${UNIT_USER_LINE}WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/${BIN_BASE}
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=${UNIT_WANTED_BY}
|
||||
EOF
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
|
||||
sc daemon-reload
|
||||
sc enable --now "$SERVICE_NAME" >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
if ! sc is-active --quiet "$SERVICE_NAME"; then
|
||||
echo "错误:服务未能启动,最近日志:" >&2
|
||||
jc -u "$SERVICE_NAME" --no-pager -n 20 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 用户级服务默认随最后一个会话结束而停止,注销后要继续跑必须开 linger
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
if loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
|
||||
echo "linger 已启用:用户注销后服务继续运行"
|
||||
elif loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
|
||||
echo "已启用 linger:用户注销后服务继续运行"
|
||||
else
|
||||
echo "提示:未能自动启用 linger,注销后服务会被停止。请手动执行:" >&2
|
||||
echo " sudo loginctl enable-linger $(id -un)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- 落一份信息备份(节点标识随机,记不住) ----------
|
||||
|
||||
{
|
||||
echo "node_id=${NODE_ID}"
|
||||
echo "node_key=${NODE_KEY}"
|
||||
echo "access_token=${ACCESS_TOKEN}"
|
||||
echo "ws_url=${WS_URL}"
|
||||
echo "port=${PORTS}"
|
||||
echo "single_stack=${SINGLE_STACK:-双栈}"
|
||||
echo "service=${SERVICE_NAME}"
|
||||
echo "install_dir=${INSTALL_DIR}"
|
||||
echo "version=${VERSION}"
|
||||
} > "${INSTALL_DIR}/node-info.txt"
|
||||
chmod 600 "${INSTALL_DIR}/node-info.txt"
|
||||
|
||||
# 本地存活自检(只打本机回环,不依赖接收方是否已登记)
|
||||
LOCAL_CHECK="失败"
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS -m 5 "http://127.0.0.1:${PORTS}/" >/dev/null 2>&1; then
|
||||
LOCAL_CHECK='正常({"status":"ok"})'
|
||||
fi
|
||||
fi
|
||||
echo "本地自检: ${LOCAL_CHECK}"
|
||||
|
||||
print_info
|
||||
Generated
+7
-7
@@ -839,8 +839,8 @@ packages:
|
||||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite@5.4.14:
|
||||
resolution: {integrity: sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==}
|
||||
vite@5.4.21:
|
||||
resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -1302,9 +1302,9 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.3': {}
|
||||
|
||||
'@vitejs/plugin-vue@5.2.1(vite@5.4.14)(vue@3.5.41)':
|
||||
'@vitejs/plugin-vue@5.2.1(vite@5.4.21)(vue@3.5.41)':
|
||||
dependencies:
|
||||
vite: 5.4.14
|
||||
vite: 5.4.21
|
||||
vue: 3.5.41
|
||||
|
||||
'@vue/compiler-core@3.5.41':
|
||||
@@ -1733,7 +1733,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite@5.4.14:
|
||||
vite@5.4.21:
|
||||
dependencies:
|
||||
esbuild: 0.21.3
|
||||
postcss: 8.5.26
|
||||
@@ -1806,7 +1806,7 @@ snapshots:
|
||||
'@shikijs/transformers': 2.1.0
|
||||
'@shikijs/types': 2.1.0
|
||||
'@types/markdown-it': 14.1.2
|
||||
'@vitejs/plugin-vue': 5.2.1(vite@5.4.14)(vue@3.5.41)
|
||||
'@vitejs/plugin-vue': 5.2.1(vite@5.4.21)(vue@3.5.41)
|
||||
'@vue/devtools-api': 7.7.10
|
||||
'@vue/shared': 3.5.41
|
||||
'@vueuse/core': 12.4.0
|
||||
@@ -1815,7 +1815,7 @@ snapshots:
|
||||
mark.js: 8.11.1
|
||||
minisearch: 7.2.0
|
||||
shiki: 2.1.0
|
||||
vite: 5.4.14
|
||||
vite: 5.4.21
|
||||
vue: 3.5.41
|
||||
optionalDependencies:
|
||||
postcss: 8.5.26
|
||||
|
||||
@@ -182,14 +182,14 @@ function getStatusClass(status: string): string {
|
||||
<tr v-if="result.dbip_asn?.asn">
|
||||
<td class="table-label">DB-IP ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.dbip_asn.asn }}
|
||||
{{ result.dbip_asn.asn }}
|
||||
<span v-if="result.dbip_asn.org" style="color: #999; font-size: 0.9em;">{{ result.dbip_asn.org }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="result.ip2location_asn?.asn">
|
||||
<td class="table-label">IP2Location ASN</td>
|
||||
<td class="table-value">
|
||||
AS{{ result.ip2location_asn.asn }}
|
||||
{{ result.ip2location_asn.asn }}
|
||||
<span v-if="result.ip2location_asn.as" style="color: #999; font-size: 0.9em;">{{ result.ip2location_asn.as }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -124,6 +124,10 @@ const config = {
|
||||
id: "cn-shenzhen",
|
||||
url: "https://cn-shenzhen.api-ipw.wsmdn.top/"
|
||||
},
|
||||
{
|
||||
label: "河南 安阳 联通",
|
||||
"id": "anyang-cu"
|
||||
}
|
||||
// {
|
||||
// label: "中国 湖北 武汉 电信",
|
||||
// id: "cn-wuhan-chinatelecom",
|
||||
|
||||
+134
-27
@@ -4,7 +4,9 @@
|
||||
# - 自动检测架构并下载最新 release 二进制
|
||||
# - 交互式输入配置(环境变量注入,无需 setting.json)
|
||||
# - 生成并启用 systemd 守护进程
|
||||
# 用法:sudo bash install.sh
|
||||
# - 非 root 运行时会提示是否改用 systemd --user(用户级服务)
|
||||
# 用法:sudo bash install.sh # 系统级安装(推荐,开机自启、独立于登录会话)
|
||||
# bash install.sh # 非 root:确认后走 systemd --user,仅对当前用户生效
|
||||
# ============================================================
|
||||
|
||||
text="
|
||||
@@ -20,15 +22,58 @@ echo "$text"
|
||||
|
||||
set -e
|
||||
|
||||
# ---------- 前置检查 ----------
|
||||
# ---------- 前置检查与 systemd 作用域 ----------
|
||||
|
||||
# system = 系统级:写 /etc/systemd/system,开机自启走 multi-user.target(需 root)
|
||||
# user = 用户级:写 ~/.config/systemd/user,开机自启走 default.target(systemd --user,无需 root)
|
||||
SYSTEMD_MODE="system"
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "错误:需要 root 权限(写入 /etc/systemd/system 与安装目录),请用 sudo 运行" >&2
|
||||
exit 1
|
||||
echo "提示:当前不是 root 环境,无法写入 /etc/systemd/system 与系统安装目录。"
|
||||
read -r -p "是否改用 systemd --user(用户级服务,仅对当前用户生效)?[y/N]: " USE_USER_SYSTEMD
|
||||
case "${USE_USER_SYSTEMD,,}" in
|
||||
y|yes) SYSTEMD_MODE="user" ;;
|
||||
*)
|
||||
echo "已取消:如需系统级安装,请用 sudo 重新运行" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "错误:未检测到 systemd(systemctl 不存在),无法创建守护进程" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 用户级实例依赖登录会话与 DBus,容器 / cron 等环境常常拿不到,提前拦掉
|
||||
if [ "$SYSTEMD_MODE" = "user" ] && ! systemctl --user list-units >/dev/null 2>&1; then
|
||||
echo "错误:当前环境无法使用 systemd --user(无用户级 systemd 实例或缺少 DBus 会话)" >&2
|
||||
echo " 可改用 sudo bash install.sh 做系统级安装" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="lemon-ipw"
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
SERVICE_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/${SERVICE_NAME}.service"
|
||||
DEFAULT_INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/lemon-ipw"
|
||||
UNIT_USER_LINE=""
|
||||
UNIT_NETWORK_LINES=""
|
||||
UNIT_WANTED_BY="default.target"
|
||||
else
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
DEFAULT_INSTALL_DIR="/opt/lemon-ipw"
|
||||
# 系统级 unit 的 User= 行与 network-online 依赖;用户级没有这两个概念
|
||||
UNIT_USER_LINE="User=root
|
||||
"
|
||||
UNIT_NETWORK_LINES="After=network-online.target
|
||||
Wants=network-online.target
|
||||
"
|
||||
UNIT_WANTED_BY="multi-user.target"
|
||||
fi
|
||||
|
||||
# 统一 systemctl / journalctl 调用(用户级自动补 --user)
|
||||
sc() { if [ "$SYSTEMD_MODE" = "user" ]; then systemctl --user "$@"; else systemctl "$@"; fi; }
|
||||
jc() { if [ "$SYSTEMD_MODE" = "user" ]; then journalctl --user "$@"; else journalctl "$@"; fi; }
|
||||
|
||||
if ! command -v wget >/dev/null 2>&1 && ! command -v curl >/dev/null 2>&1; then
|
||||
echo "错误:未找到 wget 或 curl,无法下载" >&2
|
||||
exit 1
|
||||
@@ -68,8 +113,16 @@ echo "========================================"
|
||||
echo " 配置后端节点(直接回车使用默认值)"
|
||||
echo "========================================"
|
||||
|
||||
read -r -p "安装目录 [/opt/lemon-ipw]: " INSTALL_DIR
|
||||
INSTALL_DIR=${INSTALL_DIR:-/opt/lemon-ipw}
|
||||
read -r -p "安装目录 [$DEFAULT_INSTALL_DIR]: " INSTALL_DIR
|
||||
INSTALL_DIR=${INSTALL_DIR:-$DEFAULT_INSTALL_DIR}
|
||||
# unit 里 WorkingDirectory/ExecStart 按 systemd 语法裸写(不加引号),
|
||||
# 路径含空白会被拆成多个参数导致启动失败,这里提前拦掉
|
||||
case "$INSTALL_DIR" in
|
||||
*[[:space:]]*)
|
||||
echo "错误:安装目录不能包含空格或制表符(当前值:$INSTALL_DIR)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
read -r -p "监听端口 [8080]: " PORTS
|
||||
PORTS=${PORTS:-8080}
|
||||
@@ -82,10 +135,10 @@ read -r -p "DNS 服务器(留空=启动时自动探测系统 DNS;主从逗
|
||||
|
||||
read -r -p "DNSSEC 专用 DNS(留空=沿用上面 dns-server): " DNSSEC_DNS_SERVER
|
||||
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB)[Y/n]: " IPDB_CHOICE
|
||||
read -r -p "启用 IP 数据库 ipdb(首次启动下载约 450MB,用于 IP 归属地/ASN 类拨测)[y/N]: " IPDB_CHOICE
|
||||
case "${IPDB_CHOICE,,}" in
|
||||
n|no) IPDB="false" ;;
|
||||
*) IPDB="true" ;;
|
||||
y|yes) IPDB="true" ;;
|
||||
*) IPDB="false" ;;
|
||||
esac
|
||||
|
||||
read -r -p "CORS 允许来源(逗号分隔,留空=不限): " CORS
|
||||
@@ -106,11 +159,35 @@ NODE_KEY=""
|
||||
WS_USED_DEFAULT=""
|
||||
case "${WS_CHOICE,,}" in
|
||||
y|yes)
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;留空=贡献节点给柠檬 wss://boce-api.api-ipw.wsmdn.top/ws;逗号分隔多个将同时连接全部): " WS_URL
|
||||
read -r -p " 中间件 WS 完整地址(含 wss:// 前缀与 /ws 路径,如 wss://host:8092/ws;多个用逗号分隔将同时连接全部;留空=贡献节点给柠檬的两条公共中间件): " WS_URL
|
||||
if [ -z "$WS_URL" ]; then
|
||||
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws"
|
||||
WS_URL="wss://boce-api.api-ipw.wsmdn.top/ws,wss://middleware-1.api-ipw.wsmdn.top/ws"
|
||||
WS_USED_DEFAULT="true"
|
||||
fi
|
||||
# 多地址(逗号分隔):逐段去掉首尾空白并校验 scheme。
|
||||
# 不清理的话空格会原样写进 unit 的 Environment,节点侧解析失败后只会反复重连,很难排查。
|
||||
_ws_norm=""
|
||||
_old_ifs="$IFS"; IFS=','
|
||||
for _seg in $WS_URL; do
|
||||
_seg="${_seg#"${_seg%%[![:space:]]*}"}"
|
||||
_seg="${_seg%"${_seg##*[![:space:]]}"}"
|
||||
case "$_seg" in
|
||||
ws://*|wss://*) ;;
|
||||
"")
|
||||
IFS="$_old_ifs"
|
||||
echo "错误:WS 地址含空段(检查是否多写了逗号)" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
IFS="$_old_ifs"
|
||||
echo "错误:WS 地址的每一段都需以 ws:// 或 wss:// 开头(问题段:${_seg})" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
_ws_norm="${_ws_norm:+${_ws_norm},}${_seg}"
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
WS_URL="$_ws_norm"
|
||||
read -r -p " 注册 key(留空自动生成,中间件 ws-keys 必须包含此节点,否则注册被拒 401): " NODE_KEY
|
||||
if [ -z "$NODE_KEY" ]; then
|
||||
NODE_KEY=$(gen_uuid | tr -d '-')
|
||||
@@ -141,6 +218,11 @@ echo " 配置汇总"
|
||||
echo "========================================"
|
||||
echo "安装目录: $INSTALL_DIR"
|
||||
echo "服务名: lemon-ipw"
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
echo "服务作用域: 用户级(systemd --user,仅对当前用户生效)"
|
||||
else
|
||||
echo "服务作用域: 系统级(systemd)"
|
||||
fi
|
||||
echo "监听端口: $PORTS"
|
||||
echo "节点 id: $NODE_ID"
|
||||
echo "单栈模式: ${SINGLE_STACK:-双栈}"
|
||||
@@ -182,7 +264,9 @@ chmod +x "$INSTALL_DIR/lemonipw"
|
||||
echo "二进制已安装到 $INSTALL_DIR/lemonipw"
|
||||
|
||||
# ---------- 生成 systemd 服务 ----------
|
||||
SERVICE_FILE="/etc/systemd/system/lemon-ipw.service"
|
||||
|
||||
# 用户级 unit 目录(~/.config/systemd/user)默认可能不存在
|
||||
mkdir -p "$(dirname "$SERVICE_FILE")"
|
||||
|
||||
# 收集环境变量(仅非空的写入,避免空值覆盖默认)
|
||||
# 值里的双引号/反斜杠需要转义,否则会提前终止 systemd 的引号包裹、损坏 unit
|
||||
@@ -216,43 +300,66 @@ fi
|
||||
cat > "$SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=Lemon IPW Backend Node
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
${UNIT_NETWORK_LINES}
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory="${INSTALL_DIR}"
|
||||
ExecStart="${INSTALL_DIR}/lemonipw"
|
||||
${UNIT_USER_LINE}WorkingDirectory=${INSTALL_DIR}
|
||||
ExecStart=${INSTALL_DIR}/lemonipw
|
||||
${ENV_LINES}Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=${UNIT_WANTED_BY}
|
||||
EOF
|
||||
|
||||
echo "已生成服务文件 $SERVICE_FILE"
|
||||
|
||||
# ---------- 启动服务 ----------
|
||||
systemctl daemon-reload
|
||||
sc daemon-reload
|
||||
# restart 而非 enable --now:重跑安装改配置后旧进程必须重启才会生效
|
||||
systemctl enable lemon-ipw
|
||||
systemctl restart lemon-ipw
|
||||
systemctl status lemon-ipw --no-pager | head -15
|
||||
sc enable lemon-ipw
|
||||
sc restart lemon-ipw
|
||||
sc status lemon-ipw --no-pager | head -15
|
||||
|
||||
# 用户级服务默认随最后一个会话结束而停止,注销后要继续跑必须开 linger
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
if loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
|
||||
echo "linger 已启用:用户注销后服务继续运行"
|
||||
elif loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then
|
||||
echo "已启用 linger:用户注销后服务继续运行"
|
||||
else
|
||||
echo "提示:未能自动启用 linger,注销后服务会被停止。请手动执行:" >&2
|
||||
echo " sudo loginctl enable-linger $(id -un)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 安装完成"
|
||||
echo "========================================"
|
||||
# 用户级服务的运维命令都要带 --user,否则看到的是系统级命名空间(空)
|
||||
if [ "$SYSTEMD_MODE" = "user" ]; then
|
||||
CTL_CMD="systemctl --user"
|
||||
JRN_CMD="journalctl --user -u"
|
||||
else
|
||||
CTL_CMD="systemctl"
|
||||
JRN_CMD="journalctl -u"
|
||||
fi
|
||||
echo "常用命令:"
|
||||
echo " systemctl status lemon-ipw # 查看状态"
|
||||
echo " journalctl -u lemon-ipw -f # 查看日志"
|
||||
echo " systemctl restart lemon-ipw # 重启(改配置后)"
|
||||
echo " $CTL_CMD status lemon-ipw # 查看状态"
|
||||
echo " $JRN_CMD lemon-ipw -f # 查看日志"
|
||||
echo " $CTL_CMD restart lemon-ipw # 重启(改配置后)"
|
||||
echo "验证:curl http://127.0.0.1:$PORTS/ 应返回 {\"status\":\"ok\"}"
|
||||
if [ -n "$WS_URL" ]; then
|
||||
echo ""
|
||||
echo "WS 通道已启用,请把节点注册信息加入中间件 setting.json 的 ws-keys:"
|
||||
echo "WS 通道已启用,请把节点注册信息加入每一个对应中间件 setting.json 的 ws-keys(缺一个该条连接会被拒 401):"
|
||||
echo " \"$NODE_ID\": \"$NODE_KEY\""
|
||||
echo " 地址清单:"
|
||||
_old_ifs="$IFS"; IFS=','
|
||||
for _seg in $WS_URL; do
|
||||
echo " - $_seg"
|
||||
done
|
||||
IFS="$_old_ifs"
|
||||
fi
|
||||
if [ -n "$WS_USED_DEFAULT" ]; then
|
||||
echo ""
|
||||
|
||||
+48
-30
@@ -43,7 +43,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/spf13/viper"
|
||||
"lemon-ipw/ssrf"
|
||||
"lemon-ipw/webtest"
|
||||
@@ -101,7 +101,8 @@ func protectedKeysIn(cfg map[string]any) []string {
|
||||
|
||||
// configRestartKeys 改了内存变量但需重启进程才完全生效的键:
|
||||
//
|
||||
// - port / trusted-proxies / cors:监听地址与 gin 中间件在启动时构建并固定
|
||||
// - port / trusted-proxies / cors:监听地址与 fiber 应用配置(buildFiberConfig,含 CORS
|
||||
// 中间件)都在启动时构建并固定
|
||||
// - ipdb:ipdb.Init 与 location / asn 路由注册都按启动时的值判定
|
||||
// - report-interval-seconds:上报 ticker 在启动时按该值创建
|
||||
// - node-id / node-key:WS 注册身份在启动时确立;运行中改动只改内存变量,
|
||||
@@ -188,7 +189,8 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
|
||||
CORS = v
|
||||
ACCEPT_DOMAINS = splitAndTrim(CORS, ",")
|
||||
case "block-private-ips":
|
||||
ssrf.SetEnabled(v != "false" && v != "0")
|
||||
// 与启动口径共用 configDisabled(main.go),避免"启动认 false、热更新认 FALSE"这类漂移
|
||||
ssrf.SetEnabled(!configDisabled(v))
|
||||
case "trusted-proxies":
|
||||
TRUSTED_PROXIES = v
|
||||
case "remote-config-url":
|
||||
@@ -235,6 +237,25 @@ func applyConfigMap(cfg map[string]any, ignore []string) (applied, unknown, rest
|
||||
continue
|
||||
}
|
||||
NODE_OTA = enabled
|
||||
case "max-response-body":
|
||||
// 热生效:改完即应用到出站客户端(applyResponseBodyLimit 内部有锁)
|
||||
n, ok := parseByteSize(v)
|
||||
if !ok {
|
||||
unknown = append(unknown, key) // 值非法:不覆盖原值,明确报给调用方
|
||||
continue
|
||||
}
|
||||
MAX_RESPONSE_BODY = n
|
||||
applyResponseBodyLimit()
|
||||
case "memory-limit":
|
||||
// 热生效:debug.SetMemoryLimit 支持运行时调整。
|
||||
// 0 表示"回到自动探测 cgroup",与启动口径一致(见 resources.go)。
|
||||
n, ok := parseByteSize(v)
|
||||
if !ok {
|
||||
unknown = append(unknown, key)
|
||||
continue
|
||||
}
|
||||
MEMORY_LIMIT = n
|
||||
applyMemoryLimit()
|
||||
default:
|
||||
unknown = append(unknown, key)
|
||||
continue
|
||||
@@ -309,6 +330,8 @@ func configSnapshot() map[string]any {
|
||||
"ws-url": WS_URL,
|
||||
"node-id": WS_NODE_ID,
|
||||
"node-ota": NODE_OTA,
|
||||
"max-response-body": MAX_RESPONSE_BODY,
|
||||
"memory-limit": MEMORY_LIMIT,
|
||||
"report-url": REPORT_URL,
|
||||
"report-interval-seconds": REPORT_INTERVAL,
|
||||
}
|
||||
@@ -388,28 +411,28 @@ func refreshRemoteConfig() (map[string]any, error) {
|
||||
// access-token 未配置时**关闭整个 HTTP 管理面**:此时节点业务接口本身也无鉴权,
|
||||
// 再暴露配置读写等于公网任何人可改节点配置。WS 通道不受影响——它由节点主动外连中间件,
|
||||
// 注册需通过中间件的 ws-keys 校验,指令只发给已注册连接,天然有鉴权。
|
||||
func registerConfigRoutes(r *gin.Engine) {
|
||||
func registerConfigRoutes(r *fiber.App) {
|
||||
g := r.Group("/v1/config")
|
||||
if ACCESS_TOKEN == "" {
|
||||
slog.Warn("config HTTP API disabled: access-token not set, use WS channel instead")
|
||||
disabled := func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
disabled := func(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "配置管理接口已关闭:节点未配置 access-token,请通过 WS 通道操作",
|
||||
})
|
||||
}
|
||||
g.GET("", disabled)
|
||||
g.PATCH("", disabled)
|
||||
g.POST("/refresh", disabled)
|
||||
g.Get("", disabled)
|
||||
g.Patch("", disabled)
|
||||
g.Post("/refresh", disabled)
|
||||
return
|
||||
}
|
||||
g.Use(tokenCheck())
|
||||
g.GET("", configGetHandler)
|
||||
g.PATCH("", configPatchHandler)
|
||||
g.POST("/refresh", configRefreshHandler)
|
||||
g.Get("", configGetHandler)
|
||||
g.Patch("", configPatchHandler)
|
||||
g.Post("/refresh", configRefreshHandler)
|
||||
}
|
||||
|
||||
func configGetHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
func configGetHandler(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"config": configSnapshot(),
|
||||
"secretKeys": sortedKeysBool(configSecretKeys),
|
||||
"restartRequiredKeys": sortedKeysBool(configRestartKeys),
|
||||
@@ -417,15 +440,13 @@ func configGetHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func configPatchHandler(c *gin.Context) {
|
||||
func configPatchHandler(c fiber.Ctx) error {
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体须为 JSON 对象:" + err.Error()})
|
||||
return
|
||||
if err := c.Bind().JSON(&body); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "请求体须为 JSON 对象:" + err.Error()})
|
||||
}
|
||||
if len(body) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体为空"})
|
||||
return
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "请求体为空"})
|
||||
}
|
||||
// 本地显式 PATCH 不受 remote-ignore-config 限制:那是"防远端覆盖"的白名单
|
||||
applied, unknown, restart := applyConfigMap(body, nil)
|
||||
@@ -433,17 +454,16 @@ func configPatchHandler(c *gin.Context) {
|
||||
persisted := false
|
||||
if p := c.Query("persist"); p == "1" || strings.EqualFold(p, "true") {
|
||||
if err := persistConfig(body, applied); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "写回 setting.json 失败:" + err.Error(),
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "写回 setting.json 失败:" + err.Error(),
|
||||
"applied": applied,
|
||||
})
|
||||
return
|
||||
}
|
||||
persisted = true
|
||||
}
|
||||
// 未要求持久化时只改内存:需重启的键不落盘,进程重启后会回到 setting.json / 远端配置的值。
|
||||
// 这是刻意的语义——PATCH 是"当前进程内的即时改动",要不要固化由调用方用 persist 明确决定。
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
return c.Status(http.StatusOK).JSON(fiber.Map{
|
||||
"applied": applied,
|
||||
"unknown": unknown,
|
||||
"restartRequired": restart,
|
||||
@@ -452,17 +472,15 @@ func configPatchHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func configRefreshHandler(c *gin.Context) {
|
||||
func configRefreshHandler(c fiber.Ctx) error {
|
||||
res, err := refreshRemoteConfig()
|
||||
if err == errNoRemoteURL {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "remote-config-url 未配置,无法刷新"})
|
||||
return
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "remote-config-url 未配置,无法刷新"})
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
|
||||
return
|
||||
return c.Status(http.StatusBadGateway).JSON(fiber.Map{"error": "拉取远端配置失败:" + err.Error(), "source": REMOTE_CONFIG_URL})
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
return c.Status(http.StatusOK).JSON(res)
|
||||
}
|
||||
|
||||
// sortedKeysBool 取 map 键的升序列表(用于向调用方说明哪些键是凭据/需重启)
|
||||
|
||||
+12
-26
@@ -5,8 +5,7 @@ go 1.26.6
|
||||
require (
|
||||
github.com/AdguardTeam/golibs v0.35.16
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/gofiber/fiber/v3 v3.4.0
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3
|
||||
github.com/klauspost/compress v1.19.1
|
||||
@@ -21,46 +20,33 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/gofiber/schema v1.8.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.1.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/likexian/gokit v0.25.16 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.72.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
)
|
||||
+33
-72
@@ -1,64 +1,42 @@
|
||||
github.com/AdguardTeam/golibs v0.35.16 h1:e9dyk7jfzpxMcyVZ8RktKV6JMeiQ9MpEhp/Ahm3j/R4=
|
||||
github.com/AdguardTeam/golibs v0.35.16/go.mod h1:lGsRwlo3R3ma5o8mgdDv3I+6DYTiCHfrhNyUamqhbK8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw=
|
||||
github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk=
|
||||
github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis=
|
||||
github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA=
|
||||
github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I=
|
||||
github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
|
||||
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3 h1:GLSAW9ypLUd6EF9QNK2Uhxew9Jzs4XMJ9gOZEFnJm7U=
|
||||
github.com/ipipdotnet/ipdb-go v1.3.3/go.mod h1:yZ+8puwe3R37a/3qRftXo40nZVQbxYDLqls9o5foexs=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
|
||||
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
|
||||
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
|
||||
@@ -67,30 +45,26 @@ github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpO
|
||||
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7 h1:OOVHPQGWDqC8tMrwFXd4Y4VrxXEL+y77YD1Rh2dLjc0=
|
||||
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20260417083004-03cc433717a7/go.mod h1:sj5LMpsqB4IWdwIrcmmBJM6m+rW/uOQLSGUPhKkqdh8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.3.0 h1:PnXjMGjkSQlwOBSyZ7hk6Fd75t7erkAhJNJgEhA3MQU=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.3.0/go.mod h1:NSQvgFwPxODpBTJI5+5Ns1AAucnx7ggW9PSRRifAT1s=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw=
|
||||
github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -101,31 +75,22 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
@@ -134,19 +99,15 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
|
||||
+604
-371
File diff suppressed because it is too large.
Load diff
+10
-13
@@ -19,7 +19,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ==================== OTA 升级(收集中心下发,节点执行) ====================
|
||||
@@ -120,35 +120,32 @@ func otaReport(c *websocket.Conn, requestID string, ok bool, stage, errMsg strin
|
||||
|
||||
// registerOTARoute 注册 POST /v1/ota(收集中心 HTTP 回退通道)。
|
||||
// 鉴权与 /v1/config 一致:access-token 未配置时整个 HTTP OTA 管理面关闭(RCE 面不能裸奔)。
|
||||
func registerOTARoute(r *gin.Engine) {
|
||||
func registerOTARoute(r *fiber.App) {
|
||||
g := r.Group("/v1/ota")
|
||||
if ACCESS_TOKEN == "" {
|
||||
slog.Warn("ota HTTP API disabled: access-token not set, use WS channel instead")
|
||||
g.POST("", func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
g.Post("", func(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "OTA 接口已关闭:节点未配置 access-token,请通过 WS 通道下发",
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
g.Use(tokenCheck())
|
||||
g.POST("", func(c *gin.Context) {
|
||||
g.Post("", func(c fiber.Ctx) error {
|
||||
if !NODE_OTA {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "节点已禁用 OTA(node-ota=false)"})
|
||||
return
|
||||
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "节点已禁用 OTA(node-ota=false)"})
|
||||
}
|
||||
var req otaRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid payload: " + err.Error()})
|
||||
}
|
||||
if err := otaValidate(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// 异步执行:下载可能耗时数分钟,HTTP 无进度通道,结果看节点日志与重连后的版本号
|
||||
go otaRun(req, nil)
|
||||
c.JSON(http.StatusAccepted, gin.H{"ok": true, "started": true})
|
||||
return c.Status(http.StatusAccepted).JSON(fiber.Map{"ok": true, "started": true})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+23
-40
@@ -12,7 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// ==================== 数据上报(节点 → 收集中心 ipw-boce) ====================
|
||||
@@ -83,35 +83,8 @@ func startNodeReporter() {
|
||||
slog.Info("node reporter enabled", "ws", WS_URL != "", "httpFallback", REPORT_URL, "interval", REPORT_INTERVAL)
|
||||
}
|
||||
|
||||
// nodeReportMiddleware /v1 组统计中间件:归属规则过滤 → 计数 → 拨测类补明细
|
||||
func nodeReportMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
// 收集中心(ipw-boce)主动调度下发的拨测带 X-Scheduler-Probe 头:
|
||||
// 该次执行由收集中心侧本地落库(source=sched/biz),本节点跳过计数与明细上报,避免双算。
|
||||
// 真实业务请求(用户/中间件转发)不带此头,仍按"节点是唯一记录者"正常上报。
|
||||
if c.Request.Header.Get("X-Scheduler-Probe") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
apiType, raw := nodeAPIFromPath(c)
|
||||
if apiType == "" {
|
||||
return
|
||||
}
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
nodeRecordAPI(apiType, status, latency)
|
||||
if nodeIsProbeType(apiType) {
|
||||
nodeRecordProbe(nodeProbeRec{
|
||||
NodeID: nodeReportNodeID(), APIType: apiType, Raw: raw,
|
||||
Query: c.Request.URL.RawQuery, Status: status, LatencyMs: latency.Milliseconds(),
|
||||
Source: "http", CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// nodeReportMiddleware 已移至 main.go(与 tokenCheck 放在一起,见该文件「鉴权 / 上报中间件」段)。
|
||||
// 这里保留的只有上报的数据面:计数、拨测明细缓冲与周期上报。
|
||||
|
||||
// nodeRecordWSProbe WS 拨测出口(ws.go wsHandleProbe 调用)。
|
||||
// 中间件已不在转发路径上计数,WS 下发的拨测同样由本节点上报(唯一记录者);
|
||||
@@ -185,9 +158,18 @@ func nodeReportNodeID() string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// nodeAPIFromPath 从 gin 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)
|
||||
func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
|
||||
path := c.FullPath() // 形如 /v1/tcping/:ip
|
||||
// nodeAPIFromPath 从 fiber 路由模板提取 apiType 与拨测目标(对齐 WS probe 的 raw 格式)。
|
||||
//
|
||||
// 路由模板经 c.Route().Path 取(形如 /v1/tcping/:ip)。fiber 的通配符不支持命名,
|
||||
// 统一是 `*`,取值走 c.Params("*")。两个 fiber 特有的坑:
|
||||
//
|
||||
// 1. c.Params / c.Route 返回的是**零拷贝字符串**(直接指向请求缓冲)。handler 执行期间
|
||||
// 这份缓冲会被复用,所以调用方必须在 c.Next() 之前取值,这里再 Clone 一道双保险。
|
||||
// 2. 通配符值**不带**前导斜杠(gin 的 `*url` 带)。原来靠 catch-all 自带 `/` 拼出
|
||||
// "a/example.com"、"v4/example.com" 的 raw 格式,这里显式补回斜杠;detail / ssl
|
||||
// 侧原本就 TrimPrefix("/"),fiber 下该操作变成无操作,结果一致。
|
||||
func nodeAPIFromPath(c fiber.Ctx) (apiType, raw string) {
|
||||
path := c.Route().Path
|
||||
if !strings.HasPrefix(path, "/v1/") {
|
||||
return "", ""
|
||||
}
|
||||
@@ -198,20 +180,21 @@ func nodeAPIFromPath(c *gin.Context) (apiType, raw string) {
|
||||
apiType = seg[0]
|
||||
switch apiType {
|
||||
case "tcping", "dnssec", "whois":
|
||||
raw = c.Param("ip")
|
||||
raw = c.Params("ip")
|
||||
if raw == "" {
|
||||
raw = c.Param("domain")
|
||||
raw = c.Params("domain")
|
||||
}
|
||||
case "speed":
|
||||
raw = c.Param("version") + c.Param("url") // v4/example.com(对齐 WS probe raw 格式)
|
||||
raw = c.Params("version") + "/" + c.Params("*") // v4/example.com(对齐 WS probe raw 格式)
|
||||
case "dns":
|
||||
raw = c.Param("type") + c.Param("domain") // a/example.com
|
||||
raw = c.Params("type") + "/" + c.Params("*") // a/example.com
|
||||
case "detail", "ssl":
|
||||
raw = strings.TrimPrefix(c.Param("url"), "/")
|
||||
raw = strings.TrimPrefix(c.Params("*"), "/")
|
||||
case "location", "asn":
|
||||
raw = c.Param("ip")
|
||||
raw = c.Params("ip")
|
||||
}
|
||||
return apiType, raw
|
||||
// Clone:切断对请求缓冲的引用(见上方说明 1)
|
||||
return strings.Clone(apiType), strings.Clone(raw)
|
||||
}
|
||||
|
||||
// nodeReportLoop 周期上报:WS 在线走 WS 广播(所有已注册中间件),否则 HTTP POST 收集中心
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// 本文件集中放"资源上限"的处理:单次拨测的响应体上限、Go 运行时的软内存上限。
|
||||
// 起因:1c1g 节点被单个请求打爆(响应体整块进堆,见 webtest.readBodySize 注释)。
|
||||
//
|
||||
// **本文件不读配置源**(不碰 os.Getenv / viper):配置的读取口径统一在 main.go 的 readConfig,
|
||||
// 这里只负责"拿到值之后怎么用" —— 字节后缀解析(parseByteSize)与应用到运行时(apply*)。
|
||||
|
||||
// defaultMaxResponseBody 单次拨测响应体的默认上限(32 MiB,解压后)。
|
||||
//
|
||||
// 取 32MB 的理由:detail/speed/ssl 都只需要一个字节数,正常网页远小于此值;
|
||||
// 而节点 RAM 只有 GB 级,没有上限时一个几百 MB 的响应体就能吃掉全部内存。
|
||||
// 超过上限不是截断上报,而是直接返回错误 —— 报一个"截断后的大小/速度"比报错更误导人。
|
||||
const defaultMaxResponseBody int64 = 32 << 20
|
||||
|
||||
// zstdMaxWindow zstd 解码窗口上限(8 MiB)。
|
||||
//
|
||||
// klauspost/compress 默认允许 512MB 窗口、64GiB 解码量,默认并发 4。窗口是在解析帧头时
|
||||
// 就按流里声明的值分配的,早于任何输出,所以 max-response-body 拦不住它 ——
|
||||
// 一条声明超大窗口的恶意 zstd 流即可让节点瞬间吃掉几百 MB。
|
||||
// HTTP 场景下正常服务端的窗口远小于此值(RFC 8878 同样建议流式场景限制窗口)。
|
||||
const zstdMaxWindow = 8 << 20
|
||||
|
||||
// parseByteSize 解析字节数:支持裸数字与 K/M/G 后缀(KB/MB/GB、KiB/MiB/GiB 亦可),大小写不敏感。
|
||||
// 例:"33554432"、"32MB"、"8MiB"、"900M"。
|
||||
func parseByteSize(s string) (int64, bool) {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
s = strings.TrimSuffix(s, "B") // 32MB -> 32M
|
||||
s = strings.TrimSuffix(s, "I") // 32Mi -> 32M
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
mult := int64(1)
|
||||
switch s[len(s)-1] {
|
||||
case 'K':
|
||||
mult, s = 1<<10, s[:len(s)-1]
|
||||
case 'M':
|
||||
mult, s = 1<<20, s[:len(s)-1]
|
||||
case 'G':
|
||||
mult, s = 1<<30, s[:len(s)-1]
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n * mult, true
|
||||
}
|
||||
|
||||
// applyResponseBodyLimit 把当前 MAX_RESPONSE_BODY 应用到出站客户端(启动与配置热更新都走这里)。
|
||||
// SetResponseBodyLimit 内部有锁,可与在途请求并发调用。
|
||||
func applyResponseBodyLimit() {
|
||||
for _, c := range []*resty.Client{V4Client, V6Client} {
|
||||
if c != nil {
|
||||
c.SetResponseBodyLimit(MAX_RESPONSE_BODY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyMemoryLimit 设置 Go 运行时的软内存上限(等价于 GOMEMLIMIT,可用 debug.SetMemoryLimit 热改)。
|
||||
//
|
||||
// 取值口径:读全局 MEMORY_LIMIT(启动时由 resolveByteLimit 填好,配置热更新时由
|
||||
// applyConfigMap 直接赋值)。**不要在这里再解析一次配置源** —— 那样配置热更新刚写进
|
||||
// MEMORY_LIMIT 的值会被重新解析覆盖成旧值,PATCH 静默失效。
|
||||
//
|
||||
// MEMORY_LIMIT<=0 时回落到自动探测容器限额的 90%;仍探测不到就不设置(保持 Go 默认)。
|
||||
// 目的:不设时 Go 只按 GOGC 比例回收,够不到"突增";小内存机器上堆会一路涨到被内核 OOM kill,
|
||||
// 中间没有减速带。设了软上限后,逼近限额时 GC 会提前加密。
|
||||
// 注意软上限不是硬闸:必要时运行时仍会越过它,只是付出更多 GC 代价。
|
||||
func applyMemoryLimit() {
|
||||
limit := MEMORY_LIMIT
|
||||
if limit <= 0 {
|
||||
if cg := detectCgroupMemoryLimit(); cg > 0 {
|
||||
limit = cg / 10 * 9
|
||||
}
|
||||
}
|
||||
if limit <= 0 {
|
||||
debug.SetMemoryLimit(math.MaxInt64)
|
||||
slog.Info("No memory limit configured or detected, runtime keeps default GC behavior")
|
||||
return
|
||||
}
|
||||
debug.SetMemoryLimit(limit)
|
||||
slog.Info("Runtime soft memory limit applied", "bytes", limit, "mb", limit>>20)
|
||||
}
|
||||
|
||||
// detectCgroupMemoryLimit 读容器内存限额(字节)。不在容器里 / 未限额 / 读不到时返回 0。
|
||||
// cgroup v2: /sys/fs/cgroup/memory.max(无限时值为 "max")
|
||||
// cgroup v1: /sys/fs/cgroup/memory/memory.limit_in_bytes(无限时是接近 int64 上限的大数)
|
||||
func detectCgroupMemoryLimit() int64 {
|
||||
for _, p := range []string{
|
||||
"/sys/fs/cgroup/memory.max",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
} {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "max" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
if n >= 1<<62 { // "无限制"的常见写法
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -17,5 +17,7 @@
|
||||
"node-ota": "true",
|
||||
"report-url": "",
|
||||
"report-token": "",
|
||||
"report-interval-seconds": 15
|
||||
"report-interval-seconds": 15,
|
||||
"max-response-body": "32MB",
|
||||
"memory-limit": ""
|
||||
}
|
||||
+73
-1
@@ -1,6 +1,13 @@
|
||||
package webtest
|
||||
|
||||
import "resty.dev/v3"
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"resty.dev/v3"
|
||||
)
|
||||
|
||||
// 出站 HTTP 客户端(定义与初始化仍在 main.go,启动时经 SetHTTPClient 注入)
|
||||
var v4Client, v6Client *resty.Client
|
||||
@@ -18,3 +25,68 @@ func httpClient(version string) *resty.Client {
|
||||
}
|
||||
return v4Client
|
||||
}
|
||||
|
||||
// newProbeRequest 构造拨测请求:开 trace(要 DNS/TCP/TLS 各段计时)、绑 context,
|
||||
// 并关掉 resty 的自动解析(SetDoNotParseResponse)—— 这一条是关键,见 readBodySize。
|
||||
//
|
||||
// 必须走这个构造函数,不要在调用点手写请求链:漏掉 DoNotParseResponse 时
|
||||
// resty 的 AutoParseResponseMiddleware 会替我们把整个响应体读进 bodyBytes
|
||||
// (Content-Type 不是 JSON/XML 时它就 readAll),响应体上限和流式计数全部失效。
|
||||
func newProbeRequest(version string, ctx context.Context) *resty.Request {
|
||||
return httpClient(version).R().
|
||||
EnableTrace().
|
||||
SetContext(ctx).
|
||||
SetDoNotParseResponse(true)
|
||||
}
|
||||
|
||||
// readBodySize 流式读完响应体,返回**解压后**的字节数。
|
||||
//
|
||||
// 不用 resp.Bytes():它只是把 resty 已经缓存的 bodyBytes 返回给调用方,而缓存动作
|
||||
// 发生在 Get() 内部 —— resty 的 AutoParseResponseMiddleware 对没注册解码器的
|
||||
// Content-Type(text/html、text/plain、octet-stream……差不多就是全部被测站点)
|
||||
// 会直接 res.readAll(),等价于 io.ReadAll,整个响应体留在堆上。
|
||||
// detail / speed / ssl 要的只是一个计数(page_size、download_speed),而响应体大小
|
||||
// 完全由被测站点决定:一个压缩炸弹(Content-Encoding: gzip/zstd)能把几百 KB 的响应
|
||||
// 放大到几百 MB,1c1g 节点单次请求即可 OOM。
|
||||
// 配合 newProbeRequest 的 DoNotParseResponse,这里边读边丢,内存恒定,
|
||||
// 与响应体大小无关。
|
||||
//
|
||||
// 兜底上限由客户端侧的 SetResponseBodyLimit(配置项 max-response-body)提供:
|
||||
// 超限时 resty 的 limitReadCloser 会返回 ErrReadExceedsThresholdLimit,
|
||||
// 这里转成可读的错误,而不是悄悄把截断的字节数当成页面大小报出去。
|
||||
func readBodySize(resp *resty.Response) (int64, error) {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
if errors.Is(err, resty.ErrReadExceedsThresholdLimit) {
|
||||
return n, fmt.Errorf("response body exceeds the limit of %d bytes (max-response-body)", responseBodyLimit(resp))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// responseBodyLimit 取本次请求实际生效的响应体上限(<=0 表示不限制)
|
||||
func responseBodyLimit(resp *resty.Response) int64 {
|
||||
if resp == nil || resp.Request == nil {
|
||||
return 0
|
||||
}
|
||||
return resp.Request.ResponseBodyLimit
|
||||
}
|
||||
|
||||
// closeResponseBody 尽力释放失败请求的响应体。
|
||||
//
|
||||
// 为什么需要:resty 除了传输错误,还会在**响应体解压失败**时提前返回
|
||||
// (例如目标回了未注册的 Content-Encoding,client.go 里 wrapContentDecompresser 直接返回
|
||||
// ErrContentDecompresserNotFound)。这时 Response.Body 已经挂上原始响应体但没有任何人关闭,
|
||||
// 而我们随后就走 `if err != nil { return nil, err }` 把 Response 丢掉 ——
|
||||
// 每个这样的请求漏一个连接/FD,且因为 DisableKeepAlives,要等 GC 终结器才回收。
|
||||
// 目标站点只要固定回一个没注册的 Content-Encoding,就能一点点耗掉节点的 FD。
|
||||
func closeResponseBody(resp *resty.Response) {
|
||||
if resp != nil && resp.Body != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
+16
-5
@@ -19,22 +19,33 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
resp, err := newProbeRequest(version, ctx).Get(url)
|
||||
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
closeResponseBody(resp) // 丢弃本次失败的响应,避免连接/FD 悬挂(见 closeResponseBody)
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
resp, err = newProbeRequest(version, ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
closeResponseBody(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
|
||||
// 必须放在 endTime 之前:改造前 resty 是在 Get() 内部读完整个响应体的,
|
||||
// 所以 total_time 一直包含响应体下载耗时,download_speed 才是"字节数/总耗时"。
|
||||
// 读体挪到 Get() 之外后若不在此处计入,total_time 会只剩到首字节的时间,
|
||||
// download_speed 会算出几百 GB/s 这种离谱值。
|
||||
bodySize, err := readBodySize(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
@@ -50,7 +61,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
dumpBytes, _ := httputil.DumpResponse(resp.RawResponse, false)
|
||||
httpStatus := resp.StatusCode()
|
||||
@@ -69,7 +80,7 @@ func SpeedTest(url string, version string) (*WebsiteSpeedTestResult, error) {
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
PageSize: bodySize,
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
+11
-3
@@ -19,7 +19,16 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
resp, err := newProbeRequest(version, ctx).Get(url)
|
||||
if err != nil {
|
||||
closeResponseBody(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
|
||||
// 放在 endTime 之前:改造前 resty 在 Get() 内部就读完了整个响应体,
|
||||
// total_time 一直包含响应体下载耗时,见 speed.go 同处注释。
|
||||
bodySize, err := readBodySize(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -29,10 +38,9 @@ func CheckSSL(url string, version string) (*SSLCheckDetail, error) {
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
body := resp.Bytes()
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
rawResp := resp.RawResponse
|
||||
|
||||
+14
-5
@@ -18,23 +18,32 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
resp, err := httpClient(version).R().EnableTrace().SetContext(ctx).Get(url)
|
||||
resp, err := newProbeRequest(version, ctx).Get(url)
|
||||
|
||||
// HTTPS 请求失败时 fallback 到 HTTP
|
||||
fallbackToHTTP := false
|
||||
if err != nil && strings.HasPrefix(url, "https://") {
|
||||
closeResponseBody(resp) // 丢弃本次失败的响应,避免连接/FD 悬挂(见 closeResponseBody)
|
||||
httpURL := strings.Replace(url, "https://", "http://", 1)
|
||||
startTime = time.Now()
|
||||
resp, err = httpClient(version).R().EnableTrace().SetContext(ctx).Get(httpURL)
|
||||
resp, err = newProbeRequest(version, ctx).Get(httpURL)
|
||||
fallbackToHTTP = true
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
closeResponseBody(resp)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 流式计数,不把响应体留在堆上(详见 readBodySize)。
|
||||
// 放在 endTime 之前:改造前 resty 在 Get() 内部就读完了整个响应体,
|
||||
// total_time 一直包含响应体下载耗时,见 speed.go 同处注释。
|
||||
bodySize, err := readBodySize(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endTime := time.Now()
|
||||
|
||||
body := resp.Bytes()
|
||||
trace := resp.Request.TraceInfo()
|
||||
|
||||
hostRecord := CleanHostRecord(trace.RemoteAddr)
|
||||
@@ -50,7 +59,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
totalTime := float64(endTime.Sub(startTime).Milliseconds())
|
||||
var downloadSpeed float64
|
||||
if totalTime > 0 {
|
||||
downloadSpeed = float64(len(body)) / 1024.0 / (totalTime / 1000.0)
|
||||
downloadSpeed = float64(bodySize) / 1024.0 / (totalTime / 1000.0)
|
||||
}
|
||||
|
||||
httpStatus := resp.StatusCode()
|
||||
@@ -68,7 +77,7 @@ func CheckWebsite(url string, version string) (*WebsiteCheckDetail, error) {
|
||||
HTTPConnectTime: httpConnectTime,
|
||||
FirstByteTime: firstByteTime,
|
||||
TotalTime: totalTime,
|
||||
PageSize: int64(len(body)),
|
||||
PageSize: bodySize,
|
||||
DownloadSpeed: downloadSpeed,
|
||||
IsReachable: true,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := websiteCache.Load(testURL); ok {
|
||||
entry := cached.(websiteCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
websiteCache.Delete(testURL)
|
||||
@@ -52,14 +52,17 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
},
|
||||
)
|
||||
result := &WebsiteCheckResult{IPv4: ipv4, IPv6: ipv6}
|
||||
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now()})
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.WebsiteCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
websiteCache.Store(testURL, websiteCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "ssl":
|
||||
testURL := normalizeURL(raw)
|
||||
if cached, ok := sslCache.Load(testURL); ok {
|
||||
entry := cached.(sslCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
sslCache.Delete(testURL)
|
||||
@@ -74,7 +77,10 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
},
|
||||
)
|
||||
result := &SSLCheckResult{IPv4: ipv4, IPv6: ipv6}
|
||||
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now()})
|
||||
failed := dualAnyFailed(result.IPv4, result.IPv6, func(d *webtest.SSLCheckDetail) bool {
|
||||
return d != nil && !d.IsReachable
|
||||
})
|
||||
sslCache.Store(testURL, sslCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "speed":
|
||||
@@ -87,7 +93,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
cacheKey := fmt.Sprintf("%s:%s", testURL, version)
|
||||
if cached, ok := speedCache.Load(cacheKey); ok {
|
||||
entry := cached.(speedCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
speedCache.Delete(cacheKey)
|
||||
@@ -95,7 +101,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
r, e := webtest.SpeedTest(testURL, version)
|
||||
if e != nil {
|
||||
errorResult := &webtest.WebsiteSpeedTestResult{HostRecord: "Error: " + e.Error()}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now()})
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: errorResult, timestamp: time.Now(), failed: true})
|
||||
return 200, errorResult
|
||||
}
|
||||
speedCache.Store(cacheKey, speedCacheEntry{result: r, timestamp: time.Now()})
|
||||
@@ -123,7 +129,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", host, port, n)
|
||||
if cached, ok := pingCache.Load(cacheKey); ok {
|
||||
entry := cached.(pingCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
pingCache.Delete(cacheKey)
|
||||
@@ -138,7 +144,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
},
|
||||
)
|
||||
result := &TCPingResult{IPv4: ipv4, IPv6: ipv6}
|
||||
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now()})
|
||||
// 只有双栈都失败才算整体失败,单侧失败仍返回了可用的延迟数据(口径同 HTTP handler)
|
||||
failed := tcpingFailed(ipv4) && tcpingFailed(ipv6)
|
||||
pingCache.Store(cacheKey, pingCacheEntry{result: result, timestamp: time.Now(), failed: failed})
|
||||
return 200, result
|
||||
|
||||
case "dns":
|
||||
@@ -190,7 +198,7 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
|
||||
}
|
||||
if cached, ok := whoisCache.Load(raw); ok {
|
||||
entry := cached.(whoisCacheEntry)
|
||||
if time.Since(entry.timestamp) < 5*time.Minute {
|
||||
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
|
||||
return 200, entry.result
|
||||
}
|
||||
whoisCache.Delete(raw)
|
||||
|
||||
Reference in new issue
Block a user