Reapply SingboxForPanel integration on upstream stable
This commit is contained in:
436
README.md
436
README.md
@@ -1,24 +1,436 @@
|
|||||||
> Sponsored by [Warp](https://go.warp.dev/sing-box), built for coding with multiple AI agents
|
> Sponsored by CodeX
|
||||||
|
|
||||||
<a href="https://go.warp.dev/sing-box">
|
# sing-box Xboard Fork
|
||||||
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/raw/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
---
|
这是一个基于上游 [SagerNet/sing-box](https://github.com/SagerNet/sing-box) 的定制分支,主要面向 Xboard / UniProxy 面板联动部署场景。
|
||||||
|
|
||||||
# sing-box
|
它保留了上游 `sing-box` 内核能力,同时补充了:
|
||||||
|
|
||||||
The universal proxy platform.
|
- Xboard 动态入站服务
|
||||||
|
- 多节点托管
|
||||||
|
- 面板协议自动识别
|
||||||
|
- VLESS / REALITY 面板字段映射
|
||||||
|
- Shadowsocks 2022 用户同步与密钥处理
|
||||||
|
- AnyTLS / Trojan / Hysteria / TUIC 的本地 TLS / ACME 支持
|
||||||
|
- 安装脚本与分离式配置模板
|
||||||
|
- PROXY protocol 客户端真实 IP 传递
|
||||||
|
|
||||||
[](https://repology.org/project/sing-box/versions)
|
如果你要查看官方功能基线、通用配置文档、原始内核行为,请优先参考上游:
|
||||||
|
|
||||||
## Documentation
|
- 上游仓库:https://github.com/SagerNet/sing-box
|
||||||
|
- 上游文档:https://sing-box.sagernet.org
|
||||||
|
|
||||||
https://sing-box.sagernet.org
|
## 适用场景
|
||||||
|
|
||||||
|
这个仓库更适合以下用途:
|
||||||
|
|
||||||
|
- 从 Xboard / UniProxy 面板拉取节点配置
|
||||||
|
- 从面板拉取用户并动态更新
|
||||||
|
- 单机运行多个节点
|
||||||
|
- 给面板型机场节点准备安装脚本
|
||||||
|
- 对接面板下发的 `protocol`、`cert_config`、`accept_proxy_protocol`、REALITY 字段等
|
||||||
|
|
||||||
|
## 主要特性
|
||||||
|
|
||||||
|
- `services.xboard` 动态服务
|
||||||
|
- 支持单节点和多节点
|
||||||
|
- 协议优先从面板返回的 `protocol` 识别
|
||||||
|
- 支持面板回包控制 `accept_proxy_protocol`
|
||||||
|
- 支持 VLESS REALITY 的 `server_name`、`public_key`、`private_key`、`short_id`
|
||||||
|
- 支持 ACME:
|
||||||
|
- `auto_tls`
|
||||||
|
- `cert_mode = http`
|
||||||
|
- `cert_mode = dns`
|
||||||
|
- 当前已支持的 ACME DNS provider:
|
||||||
|
- `cloudflare`
|
||||||
|
- `alidns`
|
||||||
|
- `tencentcloud`
|
||||||
|
- `dnspod`
|
||||||
|
- `acmedns`
|
||||||
|
- 安装脚本默认生成:
|
||||||
|
- `/etc/sing-box/config.d/10-base.json`
|
||||||
|
- `/etc/sing-box/config.d/20-outbounds.json`
|
||||||
|
- 安装后的服务名为:
|
||||||
|
- `singbox.service`
|
||||||
|
|
||||||
|
## 仓库内关键文件
|
||||||
|
|
||||||
|
- [install.sh](./install.sh)
|
||||||
|
Linux 安装脚本
|
||||||
|
- [option/xboard.go](./option/xboard.go)
|
||||||
|
`services.xboard` 配置结构
|
||||||
|
- [service/xboard/service.go](./service/xboard/service.go)
|
||||||
|
Xboard 动态服务实现
|
||||||
|
- [configs](./configs)
|
||||||
|
配置示例目录
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 编译并安装
|
||||||
|
|
||||||
|
在 Linux 服务器上进入仓库目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://s3.cloudyun.top/downloads/singbox/install.sh | bash
|
||||||
|
```
|
||||||
|
`install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。
|
||||||
|
|
||||||
|
|
||||||
|
脚本会做这些事情:
|
||||||
|
|
||||||
|
1. 检查 Go 环境
|
||||||
|
2. 编译当前仓库代码
|
||||||
|
3. 生成分离配置
|
||||||
|
4. 创建 `singbox.service`
|
||||||
|
5. 启动服务
|
||||||
|
|
||||||
|
### 2. 安装过程会询问的内容
|
||||||
|
|
||||||
|
- `Panel URL`
|
||||||
|
- `Panel Token`
|
||||||
|
- 一个或多个 `Node ID`
|
||||||
|
- DNS 模式:
|
||||||
|
- `udp`
|
||||||
|
- `local`
|
||||||
|
- 当前节点前面是否有发送 PROXY protocol 的四层代理
|
||||||
|
|
||||||
|
### 3. 多节点输入规则
|
||||||
|
|
||||||
|
- 安装脚本会持续要求输入 `Node ID`
|
||||||
|
- 输入 `NO` 才结束
|
||||||
|
- 至少要有一个节点
|
||||||
|
|
||||||
|
## 运行与管理
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status singbox
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u singbox -f
|
||||||
|
```
|
||||||
|
|
||||||
|
重启服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl restart singbox
|
||||||
|
```
|
||||||
|
|
||||||
|
手动运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sing-box -D /var/lib/sing-box -C /etc/sing-box/config.d run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 推荐配置结构
|
||||||
|
|
||||||
|
建议把配置拆成两部分。
|
||||||
|
|
||||||
|
### `10-base.json`
|
||||||
|
|
||||||
|
放这些内容:
|
||||||
|
|
||||||
|
- 日志
|
||||||
|
- DNS
|
||||||
|
- `services`
|
||||||
|
- 基础路由规则
|
||||||
|
|
||||||
|
### `20-outbounds.json`
|
||||||
|
|
||||||
|
放这些内容:
|
||||||
|
|
||||||
|
- 全部出站
|
||||||
|
- 出站标签
|
||||||
|
- 你自己的分流依赖
|
||||||
|
|
||||||
|
这样更方便:
|
||||||
|
|
||||||
|
- 面板动态服务和你的自定义出站分离
|
||||||
|
- 安装脚本生成的基础配置不容易被误改
|
||||||
|
- 调整出站时不会影响 Xboard 服务主体
|
||||||
|
|
||||||
|
示例已放在:
|
||||||
|
|
||||||
|
- [configs/10-base.single-node.json](./configs/10-base.single-node.json)
|
||||||
|
- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json)
|
||||||
|
- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json)
|
||||||
|
|
||||||
|
## `services.xboard` 配置说明
|
||||||
|
|
||||||
|
配置结构定义见 [option/xboard.go](./option/xboard.go)。
|
||||||
|
|
||||||
|
### 最小单节点示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "xboard",
|
||||||
|
"panel_url": "https://panel.example.com",
|
||||||
|
"key": "replace-with-node-token",
|
||||||
|
"sync_interval": "1m",
|
||||||
|
"report_interval": "1m",
|
||||||
|
"node_id": 286
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 多节点示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "xboard",
|
||||||
|
"panel_url": "https://panel.example.com",
|
||||||
|
"key": "replace-with-node-token",
|
||||||
|
"sync_interval": "1m",
|
||||||
|
"report_interval": "1m",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"node_id": 286
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id": 774
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 当前推荐做法
|
||||||
|
|
||||||
|
- 只传 `panel_url`
|
||||||
|
- 只传 `key`
|
||||||
|
- 只传 `node_id` 或 `nodes`
|
||||||
|
|
||||||
|
下面这些字段仍然保留兼容,但常规部署一般不需要:
|
||||||
|
|
||||||
|
- `config_panel_url`
|
||||||
|
- `user_panel_url`
|
||||||
|
- `config_node_id`
|
||||||
|
- `user_node_id`
|
||||||
|
- `node_type`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 当前逻辑会优先从面板回包中的 `protocol` 自动识别协议
|
||||||
|
- `node_type` 更适合做历史兼容,不建议再依赖它做主配置
|
||||||
|
|
||||||
|
## 面板配置回包约定
|
||||||
|
|
||||||
|
### 1. 协议识别
|
||||||
|
|
||||||
|
推荐面板显式返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"protocol": "vless"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前服务会按如下顺序识别协议:
|
||||||
|
|
||||||
|
1. `protocol`
|
||||||
|
2. `node_type`
|
||||||
|
3. 如果是 Shadowsocks 且存在 `cipher`,则回退识别为 `shadowsocks`
|
||||||
|
|
||||||
|
### 2. 监听地址与端口
|
||||||
|
|
||||||
|
推荐面板返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"listen_ip": "0.0.0.0",
|
||||||
|
"server_port": 443
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. PROXY protocol
|
||||||
|
|
||||||
|
如果前面有四层代理,并且它会发送 PROXY protocol 头,需要面板返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accept_proxy_protocol": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果用户是直连节点,不要开启这个字段,否则连接会失败。
|
||||||
|
|
||||||
|
### 4. VLESS REALITY
|
||||||
|
|
||||||
|
当前支持从面板获取这些字段:
|
||||||
|
|
||||||
|
- `tls_settings.server_name`
|
||||||
|
- `tls_settings.public_key`
|
||||||
|
- `tls_settings.private_key`
|
||||||
|
- `tls_settings.short_id`
|
||||||
|
- 顶层 `server_name`
|
||||||
|
- 顶层 `public_key`
|
||||||
|
- 顶层 `private_key`
|
||||||
|
- 顶层 `short_id`
|
||||||
|
|
||||||
|
示例见:
|
||||||
|
|
||||||
|
- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json)
|
||||||
|
|
||||||
|
### 5. Shadowsocks 2022
|
||||||
|
|
||||||
|
推荐面板返回:
|
||||||
|
|
||||||
|
- `protocol: "shadowsocks"`
|
||||||
|
- `cipher`
|
||||||
|
- `server_key`
|
||||||
|
|
||||||
|
示例见:
|
||||||
|
|
||||||
|
- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json)
|
||||||
|
|
||||||
|
### 6. AnyTLS / Trojan / Hysteria / TUIC 的证书要求
|
||||||
|
|
||||||
|
这些协议通常要求本地具备可用 TLS 证书。当前支持:
|
||||||
|
|
||||||
|
- 文件证书
|
||||||
|
- 证书内容直传
|
||||||
|
- ACME 自动签发
|
||||||
|
|
||||||
|
如果面板没有下发可用证书,也没有有效 ACME 配置,常见报错是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Xboard setup error: missing certificate
|
||||||
|
```
|
||||||
|
|
||||||
|
示例见:
|
||||||
|
|
||||||
|
- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json)
|
||||||
|
|
||||||
|
## ACME 说明
|
||||||
|
|
||||||
|
当前支持:
|
||||||
|
|
||||||
|
- `auto_tls: true`
|
||||||
|
- `cert_mode: "http"`
|
||||||
|
- `cert_mode: "dns"`
|
||||||
|
|
||||||
|
### DNS-01 provider
|
||||||
|
|
||||||
|
已支持:
|
||||||
|
|
||||||
|
- `cloudflare`
|
||||||
|
- `alidns`
|
||||||
|
- `tencentcloud`
|
||||||
|
- `dnspod`
|
||||||
|
- `acmedns`
|
||||||
|
|
||||||
|
### DNSPod 说明
|
||||||
|
|
||||||
|
仓库已经内置 DNSPod provider 适配,不再依赖旧版 `github.com/libdns/dnspod` 的接口兼容。
|
||||||
|
|
||||||
|
你可以从面板下发:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cert_config": {
|
||||||
|
"cert_mode": "dns",
|
||||||
|
"dns_provider": "dnspod",
|
||||||
|
"dns_env": {
|
||||||
|
"DNSPOD_TOKEN": "id,token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以下发腾讯云凭据:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cert_config": {
|
||||||
|
"cert_mode": "dns",
|
||||||
|
"dns_provider": "tencentcloud",
|
||||||
|
"dns_env": {
|
||||||
|
"TENCENTCLOUD_SECRET_ID": "xxx",
|
||||||
|
"TENCENTCLOUD_SECRET_KEY": "xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置示例目录
|
||||||
|
|
||||||
|
[configs](./configs) 目录中已经提供了这些示例:
|
||||||
|
|
||||||
|
- [configs/10-base.single-node.json](./configs/10-base.single-node.json)
|
||||||
|
单节点基础配置
|
||||||
|
- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json)
|
||||||
|
多节点基础配置
|
||||||
|
- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json)
|
||||||
|
出站配置模板
|
||||||
|
- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json)
|
||||||
|
VLESS REALITY 面板回包
|
||||||
|
- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json)
|
||||||
|
Shadowsocks 2022 面板回包
|
||||||
|
- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json)
|
||||||
|
AnyTLS + ACME DNS 验证面板回包
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### `unsupported protocol: empty`
|
||||||
|
|
||||||
|
原因通常是:
|
||||||
|
|
||||||
|
- 面板没有返回 `protocol`
|
||||||
|
- 兼容字段 `node_type` 也为空
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 面板显式返回顶层 `protocol`
|
||||||
|
|
||||||
|
### `Xboard setup error: missing certificate`
|
||||||
|
|
||||||
|
原因通常是:
|
||||||
|
|
||||||
|
- AnyTLS / Trojan / Hysteria / TUIC 需要证书
|
||||||
|
- 面板没有下发证书文件、证书内容或 ACME 参数
|
||||||
|
|
||||||
|
### `TLS handshake: REALITY: processed invalid connection`
|
||||||
|
|
||||||
|
多数情况下表示客户端参数和当前 REALITY 节点配置不匹配,例如:
|
||||||
|
|
||||||
|
- `server_name` 错误
|
||||||
|
- `public_key` 错误
|
||||||
|
- `short_id` 错误
|
||||||
|
- 客户端实际上不是按 REALITY 模式连入
|
||||||
|
|
||||||
|
### `Server does not exist`
|
||||||
|
|
||||||
|
通常是:
|
||||||
|
|
||||||
|
- 面板里不存在该 `node_id`
|
||||||
|
- `token` 不匹配
|
||||||
|
- 拉取配置或拉取用户的节点 ID 写错
|
||||||
|
|
||||||
|
### 真实 IP 没有正确获取
|
||||||
|
|
||||||
|
请确认:
|
||||||
|
|
||||||
|
1. 前置代理确实发送了 PROXY protocol
|
||||||
|
2. 面板确实下发了 `accept_proxy_protocol: true`
|
||||||
|
|
||||||
|
否则服务只能看到上游代理 IP。
|
||||||
|
|
||||||
|
## 开发验证
|
||||||
|
|
||||||
|
近期已验证通过的命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./common/dnspod ./common/tls ./service/acme ./service/xboard
|
||||||
|
go build -trimpath -tags 'with_quic,with_utls,with_clash_api,with_gvisor,with_acme' ./cmd/sing-box
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你改动了 Xboard、协议映射、ACME 或证书相关逻辑,建议至少执行一次以上命令。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
```
|
```text
|
||||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
@@ -28,7 +440,7 @@ the Free Software Foundation, either version 3 of the License, or
|
|||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
type ManagedSSMServer interface {
|
type ManagedSSMServer interface {
|
||||||
Inbound
|
Inbound
|
||||||
SetTracker(tracker SSMTracker)
|
SetTracker(tracker SSMTracker)
|
||||||
UpdateUsers(users []string, uPSKs []string) error
|
UpdateUsers(users []string, uPSKs []string, flows []string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type SSMTracker interface {
|
type SSMTracker interface {
|
||||||
|
|||||||
401
building-install.sh
Normal file
401
building-install.sh
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# sing-box Xboard Integration Installation Script
|
||||||
|
# This script automates the installation and configuration of sing-box with Xboard support.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONFIG_DIR="/etc/sing-box"
|
||||||
|
CONFIG_MERGE_DIR="$CONFIG_DIR/config.d"
|
||||||
|
CONFIG_BASE_FILE="$CONFIG_MERGE_DIR/10-base.json"
|
||||||
|
CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/20-outbounds.json"
|
||||||
|
WORK_DIR="/var/lib/sing-box"
|
||||||
|
BINARY_PATH="/usr/local/bin/sing-box"
|
||||||
|
SERVICE_NAME="singbox"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
LEGACY_SERVICE_NAMES=("ganclient" "sing-box")
|
||||||
|
|
||||||
|
resolve_build_jobs() {
|
||||||
|
if [[ -n "${GO_BUILD_JOBS:-}" ]]; then
|
||||||
|
echo "$GO_BUILD_JOBS"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v nproc >/dev/null 2>&1; then
|
||||||
|
nproc
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v getconf >/dev/null 2>&1; then
|
||||||
|
getconf _NPROCESSORS_ONLN
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo -e "${GREEN}Welcome to singbox Installation Script${NC}"
|
||||||
|
|
||||||
|
# Check root
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo -e "${RED}This script must be run as root${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect Architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case $ARCH in
|
||||||
|
x86_64) BINARY_ARCH="amd64" ;;
|
||||||
|
aarch64) BINARY_ARCH="arm64" ;;
|
||||||
|
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Prepare directories
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
mkdir -p "$CONFIG_MERGE_DIR"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
# Check and Install Go
|
||||||
|
install_go() {
|
||||||
|
echo -e "${YELLOW}Checking Go environment...${NC}"
|
||||||
|
if command -v go >/dev/null 2>&1; then
|
||||||
|
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//' | cut -d. -f1,2)
|
||||||
|
if [[ "$(printf '%s\n' "1.24" "$GO_VERSION" | sort -V | head -n1)" == "1.24" ]]; then
|
||||||
|
echo -e "${GREEN}Go $GO_VERSION already installed.${NC}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Installing Go 1.24.7...${NC}"
|
||||||
|
GO_TAR="go1.24.7.linux-$BINARY_ARCH.tar.gz"
|
||||||
|
curl -L "https://golang.org/dl/$GO_TAR" -o "$GO_TAR"
|
||||||
|
rm -rf /usr/local/go && tar -C /usr/local -xzf "$GO_TAR"
|
||||||
|
rm "$GO_TAR"
|
||||||
|
|
||||||
|
# Add to PATH for current session
|
||||||
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
|
if ! grep -q "/usr/local/go/bin" ~/.bashrc; then
|
||||||
|
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}Go installed successfully.${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build sing-box
|
||||||
|
build_sing_box() {
|
||||||
|
echo -e "${YELLOW}Building sing-box from source...${NC}"
|
||||||
|
BUILD_JOBS="$(resolve_build_jobs)"
|
||||||
|
|
||||||
|
# Check if we are in the source directory
|
||||||
|
if [[ ! -f "go.mod" ]]; then
|
||||||
|
echo -e "${YELLOW}Source not found in current directory. Cloning repository...${NC}"
|
||||||
|
if ! command -v git >/dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}Installing git...${NC}"
|
||||||
|
apt-get update && apt-get install -y git || yum install -y git
|
||||||
|
fi
|
||||||
|
git clone https://github.com/sagernet/sing-box.git sing-box-src
|
||||||
|
cd sing-box-src
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}Found go.mod in current directory. Building from local source.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build params from Makefile
|
||||||
|
VERSION=$(git rev-parse --short HEAD 2>/dev/null || echo "custom")
|
||||||
|
# Reduced tags for safer build on smaller servers
|
||||||
|
TAGS="with_quic,with_utls,with_clash_api,with_gvisor,with_acme"
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Downloading Go modules and refreshing go.sum entries...${NC}"
|
||||||
|
if ! go mod download; then
|
||||||
|
echo -e "${RED}Failed to download Go modules.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! go mod tidy; then
|
||||||
|
echo -e "${RED}Failed to refresh Go module metadata.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Starting compilation (this may take a few minutes)...${NC}"
|
||||||
|
echo -e "${YELLOW}Using Go parallel build jobs: ${BUILD_JOBS}${NC}"
|
||||||
|
|
||||||
|
# Use -o to be explicit about output location
|
||||||
|
# Redirect stderr to stdout to see errors clearly
|
||||||
|
if ! go build -v -p "$BUILD_JOBS" -trimpath \
|
||||||
|
-o "$BINARY_PATH" \
|
||||||
|
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION' -s -w" \
|
||||||
|
-tags "$TAGS" \
|
||||||
|
./cmd/sing-box 2>&1; then
|
||||||
|
echo -e "${RED}Compilation process failed or was terminated.${NC}"
|
||||||
|
echo -e "${YELLOW}Checking system status...${NC}"
|
||||||
|
free -m
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$BINARY_PATH" ]]; then
|
||||||
|
chmod +x "$BINARY_PATH"
|
||||||
|
echo -e "${GREEN}sing-box compiled successfully and installed to $BINARY_PATH${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Compilation failed: binary not found!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_go
|
||||||
|
build_sing_box
|
||||||
|
|
||||||
|
cleanup_legacy_service() {
|
||||||
|
echo -e "${YELLOW}Cleaning up legacy services if present...${NC}"
|
||||||
|
for legacy_service_name in "${LEGACY_SERVICE_NAMES[@]}"; do
|
||||||
|
if [[ "$legacy_service_name" == "$SERVICE_NAME" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
legacy_service_file="/etc/systemd/system/${legacy_service_name}.service"
|
||||||
|
if systemctl list-unit-files | grep -q "^${legacy_service_name}\.service"; then
|
||||||
|
systemctl stop "${legacy_service_name}" 2>/dev/null || true
|
||||||
|
systemctl disable "${legacy_service_name}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -f "$legacy_service_file" ]]; then
|
||||||
|
rm -f "$legacy_service_file"
|
||||||
|
fi
|
||||||
|
if [[ -L "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" ]]; then
|
||||||
|
rm -f "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_legacy_service
|
||||||
|
|
||||||
|
# Load .env if exists
|
||||||
|
if [[ -f ".env" ]]; then
|
||||||
|
echo -e "${YELLOW}Loading configuration from .env...${NC}"
|
||||||
|
source .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Interactive Prompts
|
||||||
|
read -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL
|
||||||
|
PANEL_URL=${INPUT_URL:-$PANEL_URL}
|
||||||
|
|
||||||
|
read -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN
|
||||||
|
PANEL_TOKEN=${INPUT_TOKEN:-$PANEL_TOKEN}
|
||||||
|
|
||||||
|
read -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL
|
||||||
|
ENABLE_PROXY_PROTOCOL_HINT=${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}}
|
||||||
|
|
||||||
|
declare -a NODE_IDS
|
||||||
|
|
||||||
|
i=1
|
||||||
|
while true; do
|
||||||
|
DEFAULT_NODE_ID=""
|
||||||
|
if [[ "$i" -eq 1 && -n "$NODE_ID" ]]; then
|
||||||
|
DEFAULT_NODE_ID="$NODE_ID"
|
||||||
|
fi
|
||||||
|
if [[ -n "$DEFAULT_NODE_ID" ]]; then
|
||||||
|
read -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type NO to finish): " INPUT_ID
|
||||||
|
else
|
||||||
|
read -p "Enter Node ID for node #$i (type NO to finish): " INPUT_ID
|
||||||
|
fi
|
||||||
|
CURRENT_NODE_ID=${INPUT_ID:-$DEFAULT_NODE_ID}
|
||||||
|
if [[ "$CURRENT_NODE_ID" =~ ^([nN][oO])$ ]]; then
|
||||||
|
if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then
|
||||||
|
echo -e "${RED}At least one Node ID is required${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ -z "$CURRENT_NODE_ID" ]]; then
|
||||||
|
echo -e "${RED}Node ID is required for node #$i${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$CURRENT_NODE_ID" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo -e "${RED}Node ID must be a positive integer${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NODE_IDS+=("$CURRENT_NODE_ID")
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
|
||||||
|
NODE_COUNT=${#NODE_IDS[@]}
|
||||||
|
|
||||||
|
DNS_MODE_DEFAULT=${DNS_MODE:-udp}
|
||||||
|
read -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE
|
||||||
|
DNS_MODE=$(echo "${INPUT_DNS_MODE:-$DNS_MODE_DEFAULT}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
case "$DNS_MODE" in
|
||||||
|
udp)
|
||||||
|
DNS_SERVER_DEFAULT=${DNS_SERVER:-1.1.1.1}
|
||||||
|
DNS_SERVER_PORT_DEFAULT=${DNS_SERVER_PORT:-53}
|
||||||
|
read -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER
|
||||||
|
DNS_SERVER=${INPUT_DNS_SERVER:-$DNS_SERVER_DEFAULT}
|
||||||
|
read -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT
|
||||||
|
DNS_SERVER_PORT=${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT}
|
||||||
|
if [[ -z "$DNS_SERVER" ]]; then
|
||||||
|
echo -e "${RED}DNS server is required in udp mode${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$DNS_SERVER_PORT" =~ ^[0-9]+$ ]] || [[ "$DNS_SERVER_PORT" -lt 1 ]] || [[ "$DNS_SERVER_PORT" -gt 65535 ]]; then
|
||||||
|
echo -e "${RED}DNS server port must be an integer between 1 and 65535${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DNS_SERVER_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag": "dns-upstream",
|
||||||
|
"type": "udp",
|
||||||
|
"server": "$DNS_SERVER",
|
||||||
|
"server_port": $DNS_SERVER_PORT
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
local)
|
||||||
|
DNS_SERVER_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag": "dns-local",
|
||||||
|
"type": "local"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unsupported DNS mode: $DNS_MODE. Supported values: udp, local${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Sync time (Critical for SS 2022)
|
||||||
|
echo -e "${YELLOW}Syncing system time...${NC}"
|
||||||
|
timedatectl set-ntp true || true
|
||||||
|
|
||||||
|
if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then
|
||||||
|
echo -e "${RED}All fields are required!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up trailing slash
|
||||||
|
PANEL_URL="${PANEL_URL%/}"
|
||||||
|
|
||||||
|
SERVICE_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"type": "xboard",
|
||||||
|
"panel_url": "$PANEL_URL",
|
||||||
|
"key": "$PANEL_TOKEN",
|
||||||
|
"sync_interval": "1m",
|
||||||
|
"report_interval": "1m"
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$NODE_COUNT" -eq 1 ]]; then
|
||||||
|
SERVICE_JSON+=$(cat <<EOF
|
||||||
|
,
|
||||||
|
"node_id": ${NODE_IDS[0]}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SERVICE_JSON+=$',
|
||||||
|
"nodes": ['
|
||||||
|
for ((i=0; i<NODE_COUNT; i++)); do
|
||||||
|
NODE_BLOCK=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"node_id": ${NODE_IDS[$i]}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
NODE_BLOCK+=$'\n }'
|
||||||
|
if [[ "$i" -gt 0 ]]; then
|
||||||
|
SERVICE_JSON+=$','
|
||||||
|
fi
|
||||||
|
SERVICE_JSON+=$'\n'"$NODE_BLOCK"
|
||||||
|
done
|
||||||
|
SERVICE_JSON+=$'\n ]'
|
||||||
|
fi
|
||||||
|
SERVICE_JSON+=$'\n }'
|
||||||
|
|
||||||
|
# Generate Configuration
|
||||||
|
echo -e "${YELLOW}Generating configuration...${NC}"
|
||||||
|
cat > "$CONFIG_BASE_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info",
|
||||||
|
"timestamp": true
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"cache_file": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "$WORK_DIR/cache.db"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
${DNS_SERVER_JSON}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
${SERVICE_JSON}
|
||||||
|
],
|
||||||
|
"inbounds": [],
|
||||||
|
"route": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"protocol": "dns",
|
||||||
|
"action": "hijack-dns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$CONFIG_OUTBOUNDS_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}Base configuration written to $CONFIG_BASE_FILE${NC}"
|
||||||
|
echo -e "${GREEN}Outbound configuration written to $CONFIG_OUTBOUNDS_FILE${NC}"
|
||||||
|
echo -e "${YELLOW}Edit $CONFIG_OUTBOUNDS_FILE when adding custom sing-box outbounds.${NC}"
|
||||||
|
|
||||||
|
if [[ "$ENABLE_PROXY_PROTOCOL_HINT" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then
|
||||||
|
echo -e "${YELLOW}Proxy Protocol deployment hint enabled.${NC}"
|
||||||
|
echo -e "${YELLOW}To make real client IP reporting work, your panel node config response must include:${NC}"
|
||||||
|
echo -e "${YELLOW} \"accept_proxy_protocol\": true${NC}"
|
||||||
|
echo -e "${YELLOW}Only enable this when the upstream L4 proxy or load balancer actually sends PROXY protocol headers.${NC}"
|
||||||
|
echo -e "${YELLOW}If clients connect directly without a PROXY header, connections will fail after enabling it on the panel.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Proxy Protocol is not expected for this deployment.${NC}"
|
||||||
|
echo -e "${YELLOW}Keep panel field \"accept_proxy_protocol\" disabled or absent unless you are using an L4 proxy/LB that sends it.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create Systemd Service
|
||||||
|
echo -e "${YELLOW}Creating systemd service...${NC}"
|
||||||
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=singbox service
|
||||||
|
After=network.target nss-lookup.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
ExecStart=$BINARY_PATH -D $WORK_DIR -C $CONFIG_MERGE_DIR run
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
LimitNOFILE=infinity
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Reload and Start
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Service installed and started successfully.${NC}"
|
||||||
|
echo -e "${GREEN}Check status with: systemctl status ${SERVICE_NAME}${NC}"
|
||||||
|
echo -e "${GREEN}View logs with: journalctl -u ${SERVICE_NAME} -f${NC}"
|
||||||
|
echo -e "${GREEN}Panel config endpoint must control PROXY protocol via accept_proxy_protocol when needed.${NC}"
|
||||||
202
building-linux.sh
Normal file
202
building-linux.sh
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
|
||||||
|
MAIN_PKG="./cmd/sing-box"
|
||||||
|
GO_BIN="${GO_BIN:-go}"
|
||||||
|
CGO_ENABLED_VALUE="${CGO_ENABLED_VALUE:-0}"
|
||||||
|
BUILD_JOBS="${GO_BUILD_JOBS:-}"
|
||||||
|
|
||||||
|
DEFAULT_TARGETS=(
|
||||||
|
"linux-amd64"
|
||||||
|
"linux-arm64"
|
||||||
|
"linux-armv7"
|
||||||
|
"windows-amd64"
|
||||||
|
"windows-arm64"
|
||||||
|
"darwin-amd64"
|
||||||
|
"darwin-arm64"
|
||||||
|
)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./build.sh Build common targets
|
||||||
|
./build.sh all Build common targets
|
||||||
|
./build.sh linux-amd64 Build a single target
|
||||||
|
./build.sh linux-amd64 windows-amd64
|
||||||
|
./build.sh current Build current host platform
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
GO_BIN Go binary path, default: go
|
||||||
|
DIST_DIR Output directory, default: ./dist
|
||||||
|
CGO_ENABLED_VALUE CGO_ENABLED value, default: 0
|
||||||
|
GO_BUILD_JOBS Go build parallel jobs, default: detected CPU core count
|
||||||
|
VERSION Embedded version string, default: git describe --tags --always
|
||||||
|
BUILD_TAGS_OTHERS Override tags for non-Windows builds
|
||||||
|
BUILD_TAGS_WINDOWS Override tags for Windows builds
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local file="$1"
|
||||||
|
if [[ ! -f "$file" ]]; then
|
||||||
|
echo "Missing required file: $file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trim_file() {
|
||||||
|
local file="$1"
|
||||||
|
awk 'BEGIN{ORS=""} {gsub(/\r/, ""); print}' "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_version() {
|
||||||
|
if [[ -n "${VERSION:-}" ]]; then
|
||||||
|
printf '%s' "$VERSION"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||||
|
git -C "$ROOT_DIR" describe --tags --always 2>/dev/null || git -C "$ROOT_DIR" rev-parse --short HEAD
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf '%s' "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_build_jobs() {
|
||||||
|
if [[ -n "$BUILD_JOBS" ]]; then
|
||||||
|
printf '%s' "$BUILD_JOBS"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v nproc >/dev/null 2>&1; then
|
||||||
|
nproc
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if command -v getconf >/dev/null 2>&1; then
|
||||||
|
getconf _NPROCESSORS_ONLN
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
printf '%s' "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_target() {
|
||||||
|
local target="$1"
|
||||||
|
local goos goarch goarm="" output tags
|
||||||
|
|
||||||
|
case "$target" in
|
||||||
|
linux-amd64)
|
||||||
|
goos="linux"
|
||||||
|
goarch="amd64"
|
||||||
|
output="$DIST_DIR/sing-box-linux-amd64"
|
||||||
|
tags="$BUILD_TAGS_OTHERS"
|
||||||
|
;;
|
||||||
|
linux-arm64)
|
||||||
|
goos="linux"
|
||||||
|
goarch="arm64"
|
||||||
|
output="$DIST_DIR/sing-box-linux-arm64"
|
||||||
|
tags="$BUILD_TAGS_OTHERS"
|
||||||
|
;;
|
||||||
|
linux-armv7)
|
||||||
|
goos="linux"
|
||||||
|
goarch="arm"
|
||||||
|
goarm="7"
|
||||||
|
output="$DIST_DIR/sing-box-linux-armv7"
|
||||||
|
tags="$BUILD_TAGS_OTHERS"
|
||||||
|
;;
|
||||||
|
windows-amd64)
|
||||||
|
goos="windows"
|
||||||
|
goarch="amd64"
|
||||||
|
output="$DIST_DIR/sing-box-windows-amd64.exe"
|
||||||
|
tags="$BUILD_TAGS_WINDOWS"
|
||||||
|
;;
|
||||||
|
windows-arm64)
|
||||||
|
goos="windows"
|
||||||
|
goarch="arm64"
|
||||||
|
output="$DIST_DIR/sing-box-windows-arm64.exe"
|
||||||
|
tags="$BUILD_TAGS_WINDOWS"
|
||||||
|
;;
|
||||||
|
darwin-amd64)
|
||||||
|
goos="darwin"
|
||||||
|
goarch="amd64"
|
||||||
|
output="$DIST_DIR/sing-box-darwin-amd64"
|
||||||
|
tags="$BUILD_TAGS_OTHERS"
|
||||||
|
;;
|
||||||
|
darwin-arm64)
|
||||||
|
goos="darwin"
|
||||||
|
goarch="arm64"
|
||||||
|
output="$DIST_DIR/sing-box-darwin-arm64"
|
||||||
|
tags="$BUILD_TAGS_OTHERS"
|
||||||
|
;;
|
||||||
|
current)
|
||||||
|
goos="$("$GO_BIN" env GOOS)"
|
||||||
|
goarch="$("$GO_BIN" env GOARCH)"
|
||||||
|
output="$DIST_DIR/sing-box-${goos}-${goarch}"
|
||||||
|
if [[ "$goos" == "windows" ]]; then
|
||||||
|
output="${output}.exe"
|
||||||
|
tags="$BUILD_TAGS_WINDOWS"
|
||||||
|
else
|
||||||
|
tags="$BUILD_TAGS_OTHERS"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported target: $target" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "==> Building $target (jobs: $RESOLVED_BUILD_JOBS)"
|
||||||
|
(
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
export CGO_ENABLED="$CGO_ENABLED_VALUE"
|
||||||
|
export GOOS="$goos"
|
||||||
|
export GOARCH="$goarch"
|
||||||
|
if [[ -n "$goarm" ]]; then
|
||||||
|
export GOARM="$goarm"
|
||||||
|
else
|
||||||
|
unset GOARM 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$GO_BIN" build -v -p "$RESOLVED_BUILD_JOBS" -trimpath \
|
||||||
|
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$VERSION_VALUE' $LDFLAGS_SHARED -s -w -buildid=" \
|
||||||
|
-tags "$tags" \
|
||||||
|
-o "$output" \
|
||||||
|
"$MAIN_PKG"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_OTHERS"
|
||||||
|
require_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_WINDOWS"
|
||||||
|
require_file "$ROOT_DIR/release/LDFLAGS"
|
||||||
|
|
||||||
|
if ! command -v "$GO_BIN" >/dev/null 2>&1; then
|
||||||
|
echo "Go binary not found: $GO_BIN" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_TAGS_OTHERS="${BUILD_TAGS_OTHERS:-$(trim_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_OTHERS")}"
|
||||||
|
BUILD_TAGS_WINDOWS="${BUILD_TAGS_WINDOWS:-$(trim_file "$ROOT_DIR/release/DEFAULT_BUILD_TAGS_WINDOWS")}"
|
||||||
|
LDFLAGS_SHARED="$(trim_file "$ROOT_DIR/release/LDFLAGS")"
|
||||||
|
VERSION_VALUE="$(resolve_version)"
|
||||||
|
RESOLVED_BUILD_JOBS="$(resolve_build_jobs)"
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
if [[ "$#" -eq 0 || "${1:-}" == "all" ]]; then
|
||||||
|
TARGETS=("${DEFAULT_TARGETS[@]}")
|
||||||
|
else
|
||||||
|
TARGETS=("$@")
|
||||||
|
fi
|
||||||
|
|
||||||
|
for target in "${TARGETS[@]}"; do
|
||||||
|
build_target "$target"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Build completed."
|
||||||
|
echo "Output directory: $DIST_DIR"
|
||||||
363
building-windows.ps1
Normal file
363
building-windows.ps1
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||||
|
[string[]]$Targets = @("all"),
|
||||||
|
|
||||||
|
[string]$GoBin = "",
|
||||||
|
[string]$DistDir = "",
|
||||||
|
[string]$GoCacheDir = "",
|
||||||
|
[string]$GoModCacheDir = "",
|
||||||
|
[string]$CgoEnabledValue = "0",
|
||||||
|
[string]$BuildJobs = "",
|
||||||
|
[string]$Version = "",
|
||||||
|
[string]$BuildTagsOthers = "",
|
||||||
|
[string]$BuildTagsWindows = "",
|
||||||
|
[switch]$Help
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$RootDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$MainPkg = "./cmd/sing-box"
|
||||||
|
$DefaultTargets = @(
|
||||||
|
"linux-amd64",
|
||||||
|
"linux-arm64",
|
||||||
|
"linux-armv7",
|
||||||
|
"windows-amd64",
|
||||||
|
"windows-arm64",
|
||||||
|
"darwin-amd64",
|
||||||
|
"darwin-arm64"
|
||||||
|
)
|
||||||
|
|
||||||
|
function Show-Usage {
|
||||||
|
@'
|
||||||
|
Usage:
|
||||||
|
.\building-windows.ps1
|
||||||
|
.\building-windows.ps1 all
|
||||||
|
.\building-windows.ps1 linux-amd64
|
||||||
|
.\building-windows.ps1 linux-amd64 windows-amd64
|
||||||
|
.\building-windows.ps1 current
|
||||||
|
|
||||||
|
Optional parameters:
|
||||||
|
-GoBin <path> Go binary path
|
||||||
|
-DistDir <path> Output directory, default: .\dist
|
||||||
|
-GoCacheDir <path> Go build cache directory, default: .\.cache\go-build
|
||||||
|
-GoModCacheDir <path> Go module cache directory, default: .\.cache\gomod
|
||||||
|
-CgoEnabledValue <0|1> CGO_ENABLED value, default: 0
|
||||||
|
-BuildJobs <int> Go build parallel jobs, default: GO_BUILD_JOBS or CPU core count
|
||||||
|
-Version <string> Embedded version, default: git describe --tags --always
|
||||||
|
-BuildTagsOthers <string> Override non-Windows build tags
|
||||||
|
-BuildTagsWindows <string> Override Windows build tags
|
||||||
|
'@ | Write-Host
|
||||||
|
}
|
||||||
|
|
||||||
|
function Require-File {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||||
|
throw "Missing required file: $Path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-TrimmedFile {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
return ((Get-Content -LiteralPath $Path -Raw) -replace "`r", "").Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-GoBinary {
|
||||||
|
param([string]$RequestedGoBin)
|
||||||
|
|
||||||
|
if ($RequestedGoBin) {
|
||||||
|
if (-not (Test-Path -LiteralPath $RequestedGoBin -PathType Leaf)) {
|
||||||
|
throw "Go binary not found: $RequestedGoBin"
|
||||||
|
}
|
||||||
|
return (Resolve-Path -LiteralPath $RequestedGoBin).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = Get-Command go -ErrorAction SilentlyContinue
|
||||||
|
if ($command) {
|
||||||
|
return $command.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaultPath = "C:\Program Files\Go\bin\go.exe"
|
||||||
|
if (Test-Path -LiteralPath $defaultPath -PathType Leaf) {
|
||||||
|
return $defaultPath
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Go binary not found. Please install Go or pass -GoBin."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-Version {
|
||||||
|
param(
|
||||||
|
[string]$RequestedVersion,
|
||||||
|
[string]$RepoRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($RequestedVersion) {
|
||||||
|
return $RequestedVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
$gitCommand = Get-Command git -ErrorAction SilentlyContinue
|
||||||
|
if ($gitCommand) {
|
||||||
|
Push-Location $RepoRoot
|
||||||
|
try {
|
||||||
|
$described = git describe --tags --always 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $described) {
|
||||||
|
return $described.Trim()
|
||||||
|
}
|
||||||
|
$commit = git rev-parse --short HEAD 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0 -and $commit) {
|
||||||
|
return $commit.Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "custom"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-BuildJobs {
|
||||||
|
param([string]$RequestedBuildJobs)
|
||||||
|
|
||||||
|
if ($RequestedBuildJobs) {
|
||||||
|
return $RequestedBuildJobs
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:GO_BUILD_JOBS) {
|
||||||
|
return $env:GO_BUILD_JOBS
|
||||||
|
}
|
||||||
|
|
||||||
|
return [string][Environment]::ProcessorCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-CachePath {
|
||||||
|
param(
|
||||||
|
[string]$RequestedPath,
|
||||||
|
[string]$DefaultRelativePath
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($RequestedPath) {
|
||||||
|
return [System.IO.Path]::GetFullPath($RequestedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.IO.Path]::GetFullPath((Join-Path $RootDir $DefaultRelativePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TargetConfig {
|
||||||
|
param([string]$Target)
|
||||||
|
|
||||||
|
switch ($Target) {
|
||||||
|
"linux-amd64" {
|
||||||
|
return @{
|
||||||
|
GOOS = "linux"
|
||||||
|
GOARCH = "amd64"
|
||||||
|
Output = "sing-box-linux-amd64"
|
||||||
|
Tags = $script:ResolvedBuildTagsOthers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"linux-arm64" {
|
||||||
|
return @{
|
||||||
|
GOOS = "linux"
|
||||||
|
GOARCH = "arm64"
|
||||||
|
Output = "sing-box-linux-arm64"
|
||||||
|
Tags = $script:ResolvedBuildTagsOthers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"linux-armv7" {
|
||||||
|
return @{
|
||||||
|
GOOS = "linux"
|
||||||
|
GOARCH = "arm"
|
||||||
|
GOARM = "7"
|
||||||
|
Output = "sing-box-linux-armv7"
|
||||||
|
Tags = $script:ResolvedBuildTagsOthers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"windows-amd64" {
|
||||||
|
return @{
|
||||||
|
GOOS = "windows"
|
||||||
|
GOARCH = "amd64"
|
||||||
|
Output = "sing-box-windows-amd64.exe"
|
||||||
|
Tags = $script:ResolvedBuildTagsWindows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"windows-arm64" {
|
||||||
|
return @{
|
||||||
|
GOOS = "windows"
|
||||||
|
GOARCH = "arm64"
|
||||||
|
Output = "sing-box-windows-arm64.exe"
|
||||||
|
Tags = $script:ResolvedBuildTagsWindows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"darwin-amd64" {
|
||||||
|
return @{
|
||||||
|
GOOS = "darwin"
|
||||||
|
GOARCH = "amd64"
|
||||||
|
Output = "sing-box-darwin-amd64"
|
||||||
|
Tags = $script:ResolvedBuildTagsOthers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"darwin-arm64" {
|
||||||
|
return @{
|
||||||
|
GOOS = "darwin"
|
||||||
|
GOARCH = "arm64"
|
||||||
|
Output = "sing-box-darwin-arm64"
|
||||||
|
Tags = $script:ResolvedBuildTagsOthers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"current" {
|
||||||
|
$goos = (& $script:ResolvedGoBin env GOOS).Trim()
|
||||||
|
$goarch = (& $script:ResolvedGoBin env GOARCH).Trim()
|
||||||
|
$output = "sing-box-$goos-$goarch"
|
||||||
|
$tags = $script:ResolvedBuildTagsOthers
|
||||||
|
if ($goos -eq "windows") {
|
||||||
|
$output += ".exe"
|
||||||
|
$tags = $script:ResolvedBuildTagsWindows
|
||||||
|
}
|
||||||
|
return @{
|
||||||
|
GOOS = $goos
|
||||||
|
GOARCH = $goarch
|
||||||
|
Output = $output
|
||||||
|
Tags = $tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
throw "Unsupported target: $Target"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-BuildTarget {
|
||||||
|
param([string]$Target)
|
||||||
|
|
||||||
|
$config = Get-TargetConfig -Target $Target
|
||||||
|
$outputPath = Join-Path $script:ResolvedDistDir $config.Output
|
||||||
|
|
||||||
|
Write-Host "==> Building $Target (jobs: $script:ResolvedBuildJobs)" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
Push-Location $RootDir
|
||||||
|
try {
|
||||||
|
$env:CGO_ENABLED = $CgoEnabledValue
|
||||||
|
$env:GOOS = $config.GOOS
|
||||||
|
$env:GOARCH = $config.GOARCH
|
||||||
|
$env:GOCACHE = $script:ResolvedGoCacheDir
|
||||||
|
$env:GOMODCACHE = $script:ResolvedGoModCacheDir
|
||||||
|
|
||||||
|
if ($config.ContainsKey("GOARM")) {
|
||||||
|
$env:GOARM = $config.GOARM
|
||||||
|
} else {
|
||||||
|
Remove-Item Env:GOARM -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
& $script:ResolvedGoBin build -v -p $script:ResolvedBuildJobs -trimpath `
|
||||||
|
-ldflags "-X 'github.com/sagernet/sing-box/constant.Version=$script:ResolvedVersion' $script:ResolvedLdflagsShared -s -w -buildid=" `
|
||||||
|
-tags $config.Tags `
|
||||||
|
-o $outputPath `
|
||||||
|
$MainPkg
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Target = $Target
|
||||||
|
Success = $false
|
||||||
|
OutputPath = $outputPath
|
||||||
|
Error = "go build exited with code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Target = $Target
|
||||||
|
Success = $true
|
||||||
|
OutputPath = $outputPath
|
||||||
|
Error = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Target = $Target
|
||||||
|
Success = $false
|
||||||
|
OutputPath = $outputPath
|
||||||
|
Error = $_.Exception.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Help) {
|
||||||
|
Show-Usage
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Targets.Count -eq 1 -and ($Targets[0] -eq "-h" -or $Targets[0] -eq "--help")) {
|
||||||
|
Show-Usage
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseTagsOthersPath = Join-Path $RootDir "release\DEFAULT_BUILD_TAGS_OTHERS"
|
||||||
|
$releaseTagsWindowsPath = Join-Path $RootDir "release\DEFAULT_BUILD_TAGS_WINDOWS"
|
||||||
|
$releaseLdflagsPath = Join-Path $RootDir "release\LDFLAGS"
|
||||||
|
|
||||||
|
Require-File -Path $releaseTagsOthersPath
|
||||||
|
Require-File -Path $releaseTagsWindowsPath
|
||||||
|
Require-File -Path $releaseLdflagsPath
|
||||||
|
|
||||||
|
$script:ResolvedGoBin = Resolve-GoBinary -RequestedGoBin $GoBin
|
||||||
|
$script:ResolvedDistDir = if ($DistDir) { $DistDir } else { Join-Path $RootDir "dist" }
|
||||||
|
$script:ResolvedDistDir = [System.IO.Path]::GetFullPath($script:ResolvedDistDir)
|
||||||
|
$script:ResolvedGoCacheDir = Resolve-CachePath -RequestedPath $GoCacheDir -DefaultRelativePath ".cache\go-build"
|
||||||
|
$script:ResolvedGoModCacheDir = Resolve-CachePath -RequestedPath $GoModCacheDir -DefaultRelativePath ".cache\gomod"
|
||||||
|
$script:ResolvedBuildJobs = Resolve-BuildJobs -RequestedBuildJobs $BuildJobs
|
||||||
|
$script:ResolvedVersion = Resolve-Version -RequestedVersion $Version -RepoRoot $RootDir
|
||||||
|
$script:ResolvedBuildTagsOthers = if ($BuildTagsOthers) { $BuildTagsOthers } else { Read-TrimmedFile -Path $releaseTagsOthersPath }
|
||||||
|
$script:ResolvedBuildTagsWindows = if ($BuildTagsWindows) { $BuildTagsWindows } else { Read-TrimmedFile -Path $releaseTagsWindowsPath }
|
||||||
|
$script:ResolvedLdflagsShared = Read-TrimmedFile -Path $releaseLdflagsPath
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $script:ResolvedDistDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $script:ResolvedGoCacheDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $script:ResolvedGoModCacheDir | Out-Null
|
||||||
|
|
||||||
|
$resolvedTargets = @()
|
||||||
|
if ($Targets.Count -eq 0 -or ($Targets.Count -eq 1 -and $Targets[0] -eq "all")) {
|
||||||
|
$resolvedTargets = $DefaultTargets
|
||||||
|
} else {
|
||||||
|
$resolvedTargets = $Targets
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = @()
|
||||||
|
foreach ($target in $resolvedTargets) {
|
||||||
|
$results += Invoke-BuildTarget -Target $target
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Build completed." -ForegroundColor Green
|
||||||
|
Write-Host "Output directory: $script:ResolvedDistDir"
|
||||||
|
Write-Host "Go build cache: $script:ResolvedGoCacheDir"
|
||||||
|
Write-Host "Go module cache: $script:ResolvedGoModCacheDir"
|
||||||
|
|
||||||
|
$successfulResults = @($results | Where-Object { $_.Success })
|
||||||
|
$failedResults = @($results | Where-Object { -not $_.Success })
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Succeeded targets:" -ForegroundColor Green
|
||||||
|
if ($successfulResults.Count -eq 0) {
|
||||||
|
Write-Host " (none)"
|
||||||
|
} else {
|
||||||
|
foreach ($result in $successfulResults) {
|
||||||
|
Write-Host " $($result.Target) -> $($result.OutputPath)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Failed targets:" -ForegroundColor Yellow
|
||||||
|
if ($failedResults.Count -eq 0) {
|
||||||
|
Write-Host " (none)"
|
||||||
|
} else {
|
||||||
|
foreach ($result in $failedResults) {
|
||||||
|
Write-Host " $($result.Target) -> $($result.Error)"
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
332
common/dnspod/provider.go
Normal file
332
common/dnspod/provider.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package dnspod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/libdns/libdns"
|
||||||
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultAPIEndpoint = "https://dnsapi.cn"
|
||||||
|
|
||||||
|
type Provider struct {
|
||||||
|
APIToken string
|
||||||
|
APIEndpoint string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiStatus struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiRecord struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
TTL string `json:"ttl"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createRecordResponse struct {
|
||||||
|
Status apiStatus `json:"status"`
|
||||||
|
Record struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"record"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listRecordsResponse struct {
|
||||||
|
Status apiStatus `json:"status"`
|
||||||
|
Records []apiRecord `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
|
||||||
|
records, err := p.listRecords(ctx, normalizeZone(zone), "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]libdns.Record, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
results = append(results, record.toLibdnsRecord())
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
||||||
|
zone = normalizeZone(zone)
|
||||||
|
if zone == "" {
|
||||||
|
return nil, E.New("DNSPod zone is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.APIToken) == "" {
|
||||||
|
return nil, E.New("DNSPod API token is empty")
|
||||||
|
}
|
||||||
|
created := make([]libdns.Record, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
requestRecord, err := normalizeInputRecord(record)
|
||||||
|
if err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
params := url.Values{
|
||||||
|
"domain": []string{zone},
|
||||||
|
"sub_domain": []string{requestRecord.Name},
|
||||||
|
"record_type": []string{requestRecord.Type},
|
||||||
|
"record_line": []string{"默认"},
|
||||||
|
"value": []string{requestRecord.Value},
|
||||||
|
}
|
||||||
|
if requestRecord.TTL > 0 {
|
||||||
|
params.Set("ttl", strconv.FormatInt(int64(requestRecord.TTL/time.Second), 10))
|
||||||
|
}
|
||||||
|
var response createRecordResponse
|
||||||
|
err = p.doForm(ctx, "Record.Create", params, &response)
|
||||||
|
if err != nil {
|
||||||
|
if requestRecord.Type == "TXT" && strings.Contains(err.Error(), "104") {
|
||||||
|
existing, listErr := p.listRecords(ctx, zone, requestRecord.Name, requestRecord.Type)
|
||||||
|
if listErr != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
for _, candidate := range existing {
|
||||||
|
if requestRecord.matches(candidate) {
|
||||||
|
created = append(created, candidate.toLibdnsRecord())
|
||||||
|
err = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return created, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
requestRecord.ID = response.Record.ID
|
||||||
|
created = append(created, requestRecord.toLibdnsRecord())
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
||||||
|
zone = normalizeZone(zone)
|
||||||
|
if zone == "" {
|
||||||
|
return nil, E.New("DNSPod zone is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(p.APIToken) == "" {
|
||||||
|
return nil, E.New("DNSPod API token is empty")
|
||||||
|
}
|
||||||
|
deleted := make([]libdns.Record, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
requestRecord, err := normalizeInputRecord(record)
|
||||||
|
if err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
candidates, err := p.listRecords(ctx, zone, requestRecord.Name, requestRecord.Type)
|
||||||
|
if err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if !requestRecord.matches(candidate) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = p.doForm(ctx, "Record.Remove", url.Values{
|
||||||
|
"domain": []string{zone},
|
||||||
|
"record_id": []string{candidate.ID},
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return deleted, err
|
||||||
|
}
|
||||||
|
deleted = append(deleted, candidate.toLibdnsRecord())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) listRecords(ctx context.Context, zone, subDomain, recordType string) ([]apiRecord, error) {
|
||||||
|
params := url.Values{
|
||||||
|
"domain": []string{zone},
|
||||||
|
}
|
||||||
|
if subDomain != "" {
|
||||||
|
params.Set("sub_domain", subDomain)
|
||||||
|
}
|
||||||
|
if recordType != "" {
|
||||||
|
params.Set("record_type", recordType)
|
||||||
|
}
|
||||||
|
var response listRecordsResponse
|
||||||
|
err := p.doForm(ctx, "Record.List", params, &response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return response.Records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) doForm(ctx context.Context, action string, params url.Values, target any) error {
|
||||||
|
endpoint := strings.TrimRight(strings.TrimSpace(p.APIEndpoint), "/")
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = defaultAPIEndpoint
|
||||||
|
}
|
||||||
|
body := url.Values{
|
||||||
|
"login_token": []string{strings.TrimSpace(p.APIToken)},
|
||||||
|
"format": []string{"json"},
|
||||||
|
}
|
||||||
|
for key, values := range params {
|
||||||
|
for _, value := range values {
|
||||||
|
body.Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/"+action, strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
client := p.HTTPClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return E.New("DNSPod ", action, " failed: HTTP ", response.StatusCode)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
var wrapper struct {
|
||||||
|
Status apiStatus `json:"status"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(data, &wrapper); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wrapper.Status.Code != "1" {
|
||||||
|
return E.New("DNSPod ", action, " failed: ", wrapper.Status.Code, " ", strings.TrimSpace(wrapper.Status.Message))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(data, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch result := target.(type) {
|
||||||
|
case *createRecordResponse:
|
||||||
|
if result.Status.Code != "1" {
|
||||||
|
return E.New("DNSPod ", action, " failed: ", result.Status.Code, " ", strings.TrimSpace(result.Status.Message))
|
||||||
|
}
|
||||||
|
case *listRecordsResponse:
|
||||||
|
if result.Status.Code != "1" {
|
||||||
|
return E.New("DNSPod ", action, " failed: ", result.Status.Code, " ", strings.TrimSpace(result.Status.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeZone(zone string) string {
|
||||||
|
return strings.TrimSuffix(strings.TrimSpace(zone), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRecordName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "@"
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(name, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRecordValue(record libdns.Record) string {
|
||||||
|
switch typed := record.(type) {
|
||||||
|
case libdns.TXT:
|
||||||
|
return typed.Text
|
||||||
|
case *libdns.TXT:
|
||||||
|
return typed.Text
|
||||||
|
default:
|
||||||
|
return record.RR().Data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type normalizedRecord struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
TTL time.Duration
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInputRecord(record libdns.Record) (normalizedRecord, error) {
|
||||||
|
rr := record.RR()
|
||||||
|
name := normalizeRecordName(rr.Name)
|
||||||
|
if name == "" {
|
||||||
|
return normalizedRecord{}, E.New("DNSPod record name is empty")
|
||||||
|
}
|
||||||
|
recordType := strings.ToUpper(strings.TrimSpace(rr.Type))
|
||||||
|
if recordType == "" {
|
||||||
|
return normalizedRecord{}, E.New("DNSPod record type is empty")
|
||||||
|
}
|
||||||
|
return normalizedRecord{
|
||||||
|
Name: name,
|
||||||
|
Type: recordType,
|
||||||
|
TTL: rr.TTL,
|
||||||
|
Value: normalizeRecordValue(record),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r normalizedRecord) matches(candidate apiRecord) bool {
|
||||||
|
if normalizeRecordName(candidate.Name) != r.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Type != "" && !strings.EqualFold(candidate.Type, r.Type) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.Value != "" && candidate.Value != r.Value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if r.TTL > 0 {
|
||||||
|
candidateTTL, _ := strconv.ParseInt(candidate.TTL, 10, 64)
|
||||||
|
if time.Duration(candidateTTL)*time.Second != r.TTL {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r normalizedRecord) toLibdnsRecord() libdns.Record {
|
||||||
|
record := apiRecord{
|
||||||
|
ID: r.ID,
|
||||||
|
Name: r.Name,
|
||||||
|
Type: r.Type,
|
||||||
|
TTL: strconv.FormatInt(int64(r.TTL/time.Second), 10),
|
||||||
|
Value: r.Value,
|
||||||
|
}
|
||||||
|
return record.toLibdnsRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r apiRecord) toLibdnsRecord() libdns.Record {
|
||||||
|
ttlSeconds, _ := strconv.ParseInt(strings.TrimSpace(r.TTL), 10, 64)
|
||||||
|
ttl := time.Duration(ttlSeconds) * time.Second
|
||||||
|
name := normalizeRecordName(r.Name)
|
||||||
|
recordType := strings.ToUpper(strings.TrimSpace(r.Type))
|
||||||
|
if recordType == "TXT" {
|
||||||
|
return libdns.TXT{
|
||||||
|
Name: name,
|
||||||
|
TTL: ttl,
|
||||||
|
Text: r.Value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rr := libdns.RR{
|
||||||
|
Name: name,
|
||||||
|
TTL: ttl,
|
||||||
|
Type: recordType,
|
||||||
|
Data: r.Value,
|
||||||
|
}
|
||||||
|
parsed, err := rr.Parse()
|
||||||
|
if err != nil {
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
boxdnspod "github.com/sagernet/sing-box/common/dnspod"
|
||||||
C "github.com/sagernet/sing-box/constant"
|
C "github.com/sagernet/sing-box/constant"
|
||||||
"github.com/sagernet/sing-box/option"
|
"github.com/sagernet/sing-box/option"
|
||||||
E "github.com/sagernet/sing/common/exceptions"
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/libdns/acmedns"
|
"github.com/libdns/acmedns"
|
||||||
"github.com/libdns/alidns"
|
"github.com/libdns/alidns"
|
||||||
"github.com/libdns/cloudflare"
|
"github.com/libdns/cloudflare"
|
||||||
|
"github.com/libdns/tencentcloud"
|
||||||
"github.com/mholt/acmez/v3/acme"
|
"github.com/mholt/acmez/v3/acme"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
@@ -112,7 +114,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
|
|||||||
}
|
}
|
||||||
if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" {
|
if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" {
|
||||||
var solver certmagic.DNS01Solver
|
var solver certmagic.DNS01Solver
|
||||||
switch dnsOptions.Provider {
|
switch C.NormalizeACMEDNSProvider(dnsOptions.Provider) {
|
||||||
case C.DNSProviderAliDNS:
|
case C.DNSProviderAliDNS:
|
||||||
solver.DNSProvider = &alidns.Provider{
|
solver.DNSProvider = &alidns.Provider{
|
||||||
CredentialInfo: alidns.CredentialInfo{
|
CredentialInfo: alidns.CredentialInfo{
|
||||||
@@ -127,6 +129,17 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
|
|||||||
APIToken: dnsOptions.CloudflareOptions.APIToken,
|
APIToken: dnsOptions.CloudflareOptions.APIToken,
|
||||||
ZoneToken: dnsOptions.CloudflareOptions.ZoneToken,
|
ZoneToken: dnsOptions.CloudflareOptions.ZoneToken,
|
||||||
}
|
}
|
||||||
|
case C.DNSProviderTencentCloud:
|
||||||
|
solver.DNSProvider = &tencentcloud.Provider{
|
||||||
|
SecretId: dnsOptions.TencentCloudOptions.SecretID,
|
||||||
|
SecretKey: dnsOptions.TencentCloudOptions.SecretKey,
|
||||||
|
SessionToken: dnsOptions.TencentCloudOptions.SessionToken,
|
||||||
|
Region: dnsOptions.TencentCloudOptions.Region,
|
||||||
|
}
|
||||||
|
case C.DNSProviderDNSPod:
|
||||||
|
solver.DNSProvider = &boxdnspod.Provider{
|
||||||
|
APIToken: dnsOptions.DNSPodOptions.APIToken,
|
||||||
|
}
|
||||||
case C.DNSProviderACMEDNS:
|
case C.DNSProviderACMEDNS:
|
||||||
solver.DNSProvider = &acmedns.Provider{
|
solver.DNSProvider = &acmedns.Provider{
|
||||||
Username: dnsOptions.ACMEDNSOptions.Username,
|
Username: dnsOptions.ACMEDNSOptions.Username,
|
||||||
|
|||||||
50
configs/10-base.multi-node.json
Normal file
50
configs/10-base.multi-node.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info",
|
||||||
|
"timestamp": true
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"cache_file": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "/var/lib/sing-box/cache.db"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"tag": "dns-local",
|
||||||
|
"type": "local"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"type": "xboard",
|
||||||
|
"panel_url": "https://panel.example.com",
|
||||||
|
"key": "replace-with-node-token",
|
||||||
|
"sync_interval": "1m",
|
||||||
|
"report_interval": "1m",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"node_id": 286
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id": 774
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node_id": 815
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inbounds": [],
|
||||||
|
"route": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"protocol": "dns",
|
||||||
|
"action": "hijack-dns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
42
configs/10-base.single-node.json
Normal file
42
configs/10-base.single-node.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info",
|
||||||
|
"timestamp": true
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"cache_file": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "/var/lib/sing-box/cache.db"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"tag": "dns-upstream",
|
||||||
|
"type": "udp",
|
||||||
|
"server": "1.1.1.1",
|
||||||
|
"server_port": 53
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"type": "xboard",
|
||||||
|
"panel_url": "https://panel.example.com",
|
||||||
|
"key": "replace-with-node-token",
|
||||||
|
"sync_interval": "1m",
|
||||||
|
"report_interval": "1m",
|
||||||
|
"node_id": 286
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"inbounds": [],
|
||||||
|
"route": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"protocol": "dns",
|
||||||
|
"action": "hijack-dns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
12
configs/20-outbounds.example.json
Normal file
12
configs/20-outbounds.example.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"tag": "block"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
33
configs/panel-response.anytls-acme-dns.json
Normal file
33
configs/panel-response.anytls-acme-dns.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"protocol": "anytls",
|
||||||
|
"listen_ip": "0.0.0.0",
|
||||||
|
"server_port": 45365,
|
||||||
|
"network": null,
|
||||||
|
"networkSettings": null,
|
||||||
|
"server_name": "code.example.com",
|
||||||
|
"accept_proxy_protocol": false,
|
||||||
|
"padding_scheme": [
|
||||||
|
"stop=8",
|
||||||
|
"0=30-30",
|
||||||
|
"1=100-400",
|
||||||
|
"2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000",
|
||||||
|
"3=9-9,500-1000",
|
||||||
|
"4=500-1000",
|
||||||
|
"5=500-1000",
|
||||||
|
"6=500-1000",
|
||||||
|
"7=500-1000"
|
||||||
|
],
|
||||||
|
"cert_config": {
|
||||||
|
"cert_mode": "dns",
|
||||||
|
"domain": "code.example.com",
|
||||||
|
"dns_provider": "tencentcloud",
|
||||||
|
"dns_env": {
|
||||||
|
"TENCENTCLOUD_SECRET_ID": "replace-with-secret-id",
|
||||||
|
"TENCENTCLOUD_SECRET_KEY": "replace-with-secret-key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"base_config": {
|
||||||
|
"push_interval": 60,
|
||||||
|
"pull_interval": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
16
configs/panel-response.shadowsocks2022.json
Normal file
16
configs/panel-response.shadowsocks2022.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"protocol": "shadowsocks",
|
||||||
|
"listen_ip": "0.0.0.0",
|
||||||
|
"server_port": 30009,
|
||||||
|
"network": null,
|
||||||
|
"networkSettings": null,
|
||||||
|
"cipher": "2022-blake3-aes-256-gcm",
|
||||||
|
"plugin": null,
|
||||||
|
"plugin_opts": null,
|
||||||
|
"server_key": "NjQzMWVlNjVmYTkwODk0OTMyOTg3MzZmYzczMmFlMTI=",
|
||||||
|
"accept_proxy_protocol": false,
|
||||||
|
"base_config": {
|
||||||
|
"push_interval": 60,
|
||||||
|
"pull_interval": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
21
configs/panel-response.vless-reality.json
Normal file
21
configs/panel-response.vless-reality.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"protocol": "vless",
|
||||||
|
"listen_ip": "0.0.0.0",
|
||||||
|
"server_port": 18443,
|
||||||
|
"network": "tcp",
|
||||||
|
"tls": 2,
|
||||||
|
"flow": "xtls-rprx-vision",
|
||||||
|
"accept_proxy_protocol": false,
|
||||||
|
"tls_settings": {
|
||||||
|
"server_name": "git.example.com",
|
||||||
|
"server_port": "443",
|
||||||
|
"public_key": "replace-with-client-visible-public-key",
|
||||||
|
"private_key": "replace-with-server-private-key",
|
||||||
|
"short_id": "0123456789abcdef",
|
||||||
|
"allow_insecure": false
|
||||||
|
},
|
||||||
|
"base_config": {
|
||||||
|
"push_interval": 60,
|
||||||
|
"pull_interval": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package constant
|
package constant
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultDNSTTL = 600
|
DefaultDNSTTL = 600
|
||||||
)
|
)
|
||||||
@@ -31,7 +33,28 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DNSProviderAliDNS = "alidns"
|
DNSProviderAliDNS = "alidns"
|
||||||
DNSProviderCloudflare = "cloudflare"
|
DNSProviderCloudflare = "cloudflare"
|
||||||
DNSProviderACMEDNS = "acmedns"
|
DNSProviderACMEDNS = "acmedns"
|
||||||
|
DNSProviderTencentCloud = "tencentcloud"
|
||||||
|
DNSProviderDNSPod = "dnspod"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NormalizeACMEDNSProvider(provider string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
|
case "", DNSProviderAliDNS, DNSProviderCloudflare, DNSProviderACMEDNS:
|
||||||
|
return strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
case "aliyun":
|
||||||
|
return DNSProviderAliDNS
|
||||||
|
case "cf":
|
||||||
|
return DNSProviderCloudflare
|
||||||
|
case "acme-dns":
|
||||||
|
return DNSProviderACMEDNS
|
||||||
|
case "tencent", "tencentcloud", "dnspod-tencentcloud", "qcloud":
|
||||||
|
return DNSProviderTencentCloud
|
||||||
|
case "dnspod":
|
||||||
|
return DNSProviderDNSPod
|
||||||
|
default:
|
||||||
|
return strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
TypeCCM = "ccm"
|
TypeCCM = "ccm"
|
||||||
TypeOCM = "ocm"
|
TypeOCM = "ocm"
|
||||||
TypeOOMKiller = "oom-killer"
|
TypeOOMKiller = "oom-killer"
|
||||||
|
TypeXBoard = "xboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
|||||||
github.com/libdns/acmedns v0.5.0
|
github.com/libdns/acmedns v0.5.0
|
||||||
github.com/libdns/alidns v1.0.6
|
github.com/libdns/alidns v1.0.6
|
||||||
github.com/libdns/cloudflare v0.2.2
|
github.com/libdns/cloudflare v0.2.2
|
||||||
|
github.com/libdns/tencentcloud v1.4.3
|
||||||
github.com/logrusorgru/aurora v2.0.3+incompatible
|
github.com/logrusorgru/aurora v2.0.3+incompatible
|
||||||
github.com/metacubex/utls v1.8.4
|
github.com/metacubex/utls v1.8.4
|
||||||
github.com/mholt/acmez/v3 v3.1.6
|
github.com/mholt/acmez/v3 v3.1.6
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import (
|
|||||||
"github.com/sagernet/sing-box/protocol/vmess"
|
"github.com/sagernet/sing-box/protocol/vmess"
|
||||||
"github.com/sagernet/sing-box/service/resolved"
|
"github.com/sagernet/sing-box/service/resolved"
|
||||||
"github.com/sagernet/sing-box/service/ssmapi"
|
"github.com/sagernet/sing-box/service/ssmapi"
|
||||||
|
"github.com/sagernet/sing-box/service/xboard"
|
||||||
E "github.com/sagernet/sing/common/exceptions"
|
E "github.com/sagernet/sing/common/exceptions"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ func ServiceRegistry() *service.Registry {
|
|||||||
|
|
||||||
resolved.RegisterService(registry)
|
resolved.RegisterService(registry)
|
||||||
ssmapi.RegisterService(registry)
|
ssmapi.RegisterService(registry)
|
||||||
|
xboard.RegisterService(registry)
|
||||||
|
|
||||||
registerDERPService(registry)
|
registerDERPService(registry)
|
||||||
registerCCMService(registry)
|
registerCCMService(registry)
|
||||||
|
|||||||
576
install.sh
Normal file
576
install.sh
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
CONFIG_DIR="/etc/sing-box"
|
||||||
|
CONFIG_MERGE_DIR="$CONFIG_DIR/config.d"
|
||||||
|
CONFIG_BASE_FILE="$CONFIG_MERGE_DIR/10-base.json"
|
||||||
|
CONFIG_OUTBOUNDS_FILE="$CONFIG_MERGE_DIR/20-outbounds.json"
|
||||||
|
WORK_DIR="/var/lib/sing-box"
|
||||||
|
BINARY_PATH="/usr/local/bin/sing-box"
|
||||||
|
SERVICE_NAME="singbox"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
LEGACY_SERVICE_NAMES=("ganclient" "sing-box")
|
||||||
|
RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://s3.cloudyun.top/downloads/singbox}"
|
||||||
|
PUBLISHED_SCRIPT_URL="${PUBLISHED_SCRIPT_URL:-https://s3.cloudyun.top/downloads/singbox/install.sh}"
|
||||||
|
V2BX_DETECTED=0
|
||||||
|
V2BX_CONFIG_PATH=""
|
||||||
|
UNINSTALL_V2BX_DEFAULT="${UNINSTALL_V2BX_DEFAULT:-n}"
|
||||||
|
SCRIPT_VERSION="${SCRIPT_VERSION:-v1.2.4}"
|
||||||
|
declare -a V2BX_IMPORTED_NODE_IDS=()
|
||||||
|
|
||||||
|
echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}"
|
||||||
|
echo -e "${GREEN}Script version: ${SCRIPT_VERSION}${NC}"
|
||||||
|
echo -e "${YELLOW}Published install script: ${PUBLISHED_SCRIPT_URL}${NC}"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo -e "${RED}This script must be run as root${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OS="$(uname -s)"
|
||||||
|
if [[ "$OS" != "Linux" ]]; then
|
||||||
|
echo -e "${RED}This install script currently supports Linux only. Current OS: ${OS}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -t 0 ]]; then
|
||||||
|
if [[ -r /dev/tty ]]; then
|
||||||
|
exec 3</dev/tty
|
||||||
|
else
|
||||||
|
echo -e "${RED}Interactive input requires a TTY. Please run this script in a terminal.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
exec 3<&0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) BINARY_ARCH="amd64" ;;
|
||||||
|
aarch64|arm64) BINARY_ARCH="arm64" ;;
|
||||||
|
armv7l|armv7) BINARY_ARCH="armv7" ;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unsupported architecture: $ARCH${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
DOWNLOAD_TARGET="${DOWNLOAD_TARGET:-linux-${BINARY_ARCH}}"
|
||||||
|
DOWNLOAD_URL="${DOWNLOAD_URL:-${RELEASE_BASE_URL}/sing-box-${DOWNLOAD_TARGET}}"
|
||||||
|
TMP_BINARY="$(mktemp)"
|
||||||
|
|
||||||
|
sanitize_value() {
|
||||||
|
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_node_id_input() {
|
||||||
|
local value
|
||||||
|
value="$(sanitize_value "$1")"
|
||||||
|
value="${value#[}"
|
||||||
|
value="${value%]}"
|
||||||
|
value="${value//,/ }"
|
||||||
|
value="${value//;/ }"
|
||||||
|
value="${value//\"/}"
|
||||||
|
value="${value//\'/}"
|
||||||
|
value="$(printf '%s' "$value" | tr -s '[:space:]' ' ')"
|
||||||
|
sanitize_value "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
append_unique_node_id() {
|
||||||
|
local normalized_value
|
||||||
|
local node_id_part
|
||||||
|
local existing_node_id
|
||||||
|
|
||||||
|
normalized_value="$(normalize_node_id_input "$1")"
|
||||||
|
if [[ -z "$normalized_value" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -r -a NORMALIZED_NODE_ID_PARTS <<< "$normalized_value"
|
||||||
|
for node_id_part in "${NORMALIZED_NODE_ID_PARTS[@]}"; do
|
||||||
|
if ! [[ "$node_id_part" =~ ^[0-9]+$ ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
for existing_node_id in "${V2BX_IMPORTED_NODE_IDS[@]}"; do
|
||||||
|
if [[ "$existing_node_id" == "$node_id_part" ]]; then
|
||||||
|
node_id_part=""
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -n "$node_id_part" ]]; then
|
||||||
|
V2BX_IMPORTED_NODE_IDS+=("$node_id_part")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
find_v2bx_config() {
|
||||||
|
local candidate
|
||||||
|
for candidate in \
|
||||||
|
"/etc/V2bX/config.json" \
|
||||||
|
"/usr/local/V2bX/config.json" \
|
||||||
|
"/etc/v2bx/config.json" \
|
||||||
|
"/usr/local/etc/V2bX/config.json"
|
||||||
|
do
|
||||||
|
if [[ -f "$candidate" ]]; then
|
||||||
|
V2BX_CONFIG_PATH="$candidate"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_v2bx() {
|
||||||
|
if command -v v2bx >/dev/null 2>&1; then
|
||||||
|
V2BX_DETECTED=1
|
||||||
|
fi
|
||||||
|
if find_v2bx_config; then
|
||||||
|
V2BX_DETECTED=1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
load_v2bx_defaults() {
|
||||||
|
if [[ -z "$V2BX_CONFIG_PATH" ]] && ! find_v2bx_config; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Detected V2bX configuration: ${V2BX_CONFIG_PATH}${NC}"
|
||||||
|
V2BX_IMPORTED_NODE_IDS=()
|
||||||
|
|
||||||
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
|
local parsed
|
||||||
|
parsed="$(python3 - "$V2BX_CONFIG_PATH" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
nodes = data.get("Nodes") or []
|
||||||
|
node = nodes[0] if nodes else {}
|
||||||
|
|
||||||
|
api_host = node.get("ApiHost", "") if node else ""
|
||||||
|
api_key = node.get("ApiKey", "") if node else ""
|
||||||
|
print(f"API_HOST={api_host or ''}")
|
||||||
|
print(f"API_KEY={api_key or ''}")
|
||||||
|
for entry in nodes:
|
||||||
|
node_id = entry.get("NodeID", "")
|
||||||
|
if node_id is None:
|
||||||
|
node_id = ""
|
||||||
|
print(f"NODE_ID={node_id}")
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
if [[ -n "$parsed" ]]; then
|
||||||
|
local parsed_line
|
||||||
|
while IFS= read -r parsed_line; do
|
||||||
|
parsed_line="$(sanitize_value "$parsed_line")"
|
||||||
|
case "$parsed_line" in
|
||||||
|
API_HOST=*)
|
||||||
|
if [[ -z "${PANEL_URL:-}" ]]; then
|
||||||
|
PANEL_URL="$(sanitize_value "${parsed_line#API_HOST=}")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
API_KEY=*)
|
||||||
|
if [[ -z "${PANEL_TOKEN:-}" ]]; then
|
||||||
|
PANEL_TOKEN="$(sanitize_value "${parsed_line#API_KEY=}")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
NODE_ID=*)
|
||||||
|
append_unique_node_id "${parsed_line#NODE_ID=}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$parsed"
|
||||||
|
fi
|
||||||
|
elif command -v jq >/dev/null 2>&1; then
|
||||||
|
local parsed
|
||||||
|
parsed="$(jq -r '(.Nodes[0].ApiHost // "" | "API_HOST=" + .), (.Nodes[0].ApiKey // "" | "API_KEY=" + .), (.Nodes[]?.NodeID // "" | tostring | "NODE_ID=" + .)' "$V2BX_CONFIG_PATH" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$parsed" ]]; then
|
||||||
|
local parsed_line
|
||||||
|
while IFS= read -r parsed_line; do
|
||||||
|
parsed_line="$(sanitize_value "$parsed_line")"
|
||||||
|
case "$parsed_line" in
|
||||||
|
API_HOST=*)
|
||||||
|
if [[ -z "${PANEL_URL:-}" ]]; then
|
||||||
|
PANEL_URL="$(sanitize_value "${parsed_line#API_HOST=}")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
API_KEY=*)
|
||||||
|
if [[ -z "${PANEL_TOKEN:-}" ]]; then
|
||||||
|
PANEL_TOKEN="$(sanitize_value "${parsed_line#API_KEY=}")"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
NODE_ID=*)
|
||||||
|
append_unique_node_id "${parsed_line#NODE_ID=}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$parsed"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Neither python3 nor jq found, skipping automatic V2bX config import.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${NODE_ID:-}" && "${#V2BX_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then
|
||||||
|
NODE_ID="${V2BX_IMPORTED_NODE_IDS[0]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${#V2BX_IMPORTED_NODE_IDS[@]}" -gt 0 ]]; then
|
||||||
|
echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-<empty>}, NodeIDs=$(IFS=,; echo "${V2BX_IMPORTED_NODE_IDS[*]}")${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-<empty>}, NodeIDs=<empty>${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_v2bx_if_present() {
|
||||||
|
if [[ "$V2BX_DETECTED" -ne 1 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! command -v v2bx >/dev/null 2>&1; then
|
||||||
|
echo -e "${YELLOW}V2bX config detected but 'v2bx' command not found, skipping stop/disable.${NC}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Detected V2bX, stopping and disabling it before continuing...${NC}"
|
||||||
|
v2bx stop || true
|
||||||
|
v2bx disable || true
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_uninstall_v2bx() {
|
||||||
|
if [[ "$V2BX_DETECTED" -ne 1 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! command -v v2bx >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -u 3 -p "Detected V2bX. Uninstall it now? [${UNINSTALL_V2BX_DEFAULT}]: " INPUT_UNINSTALL_V2BX
|
||||||
|
local uninstall_v2bx_answer
|
||||||
|
uninstall_v2bx_answer=${INPUT_UNINSTALL_V2BX:-$UNINSTALL_V2BX_DEFAULT}
|
||||||
|
|
||||||
|
if [[ "$uninstall_v2bx_answer" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then
|
||||||
|
echo -e "${YELLOW}Running: v2bx uninstall${NC}"
|
||||||
|
v2bx uninstall
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Keeping existing V2bX installation.${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
download_binary() {
|
||||||
|
echo -e "${YELLOW}Downloading sing-box release binary...${NC}"
|
||||||
|
echo -e "${YELLOW}Target: ${DOWNLOAD_TARGET}${NC}"
|
||||||
|
echo -e "${YELLOW}URL: ${DOWNLOAD_URL}${NC}"
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
if ! curl -fL "${DOWNLOAD_URL}" -o "${TMP_BINARY}"; then
|
||||||
|
echo -e "${RED}Failed to download release binary with curl.${NC}"
|
||||||
|
rm -f "${TMP_BINARY}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
if ! wget -O "${TMP_BINARY}" "${DOWNLOAD_URL}"; then
|
||||||
|
echo -e "${RED}Failed to download release binary with wget.${NC}"
|
||||||
|
rm -f "${TMP_BINARY}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}Neither curl nor wget is installed.${NC}"
|
||||||
|
rm -f "${TMP_BINARY}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0755 "${TMP_BINARY}" "${BINARY_PATH}"
|
||||||
|
rm -f "${TMP_BINARY}"
|
||||||
|
|
||||||
|
if [[ ! -x "${BINARY_PATH}" ]]; then
|
||||||
|
echo -e "${RED}Binary install failed: ${BINARY_PATH} not executable.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}sing-box downloaded and installed to ${BINARY_PATH}${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_legacy_service() {
|
||||||
|
echo -e "${YELLOW}Cleaning up legacy services if present...${NC}"
|
||||||
|
for legacy_service_name in "${LEGACY_SERVICE_NAMES[@]}"; do
|
||||||
|
if [[ "$legacy_service_name" == "$SERVICE_NAME" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
legacy_service_file="/etc/systemd/system/${legacy_service_name}.service"
|
||||||
|
if systemctl list-unit-files | grep -q "^${legacy_service_name}\.service"; then
|
||||||
|
systemctl stop "${legacy_service_name}" 2>/dev/null || true
|
||||||
|
systemctl disable "${legacy_service_name}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ -f "$legacy_service_file" ]]; then
|
||||||
|
rm -f "$legacy_service_file"
|
||||||
|
fi
|
||||||
|
if [[ -L "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service" ]]; then
|
||||||
|
rm -f "/etc/systemd/system/multi-user.target.wants/${legacy_service_name}.service"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_v2bx
|
||||||
|
stop_v2bx_if_present
|
||||||
|
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
mkdir -p "$CONFIG_MERGE_DIR"
|
||||||
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
if [[ -f ".env" ]]; then
|
||||||
|
echo -e "${YELLOW}Loading configuration from .env...${NC}"
|
||||||
|
source .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
load_v2bx_defaults || true
|
||||||
|
|
||||||
|
PANEL_URL="$(sanitize_value "${PANEL_URL:-}")"
|
||||||
|
PANEL_TOKEN="$(sanitize_value "${PANEL_TOKEN:-}")"
|
||||||
|
NODE_ID="$(sanitize_value "${NODE_ID:-}")"
|
||||||
|
ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${ENABLE_PROXY_PROTOCOL_HINT:-n}")"
|
||||||
|
|
||||||
|
download_binary
|
||||||
|
cleanup_legacy_service
|
||||||
|
|
||||||
|
read -u 3 -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL
|
||||||
|
PANEL_URL="$(sanitize_value "${INPUT_URL:-$PANEL_URL}")"
|
||||||
|
|
||||||
|
read -u 3 -p "Enter Panel Token (Node Key) [${PANEL_TOKEN}]: " INPUT_TOKEN
|
||||||
|
PANEL_TOKEN="$(sanitize_value "${INPUT_TOKEN:-$PANEL_TOKEN}")"
|
||||||
|
|
||||||
|
read -u 3 -p "This node is behind an L4 proxy/LB that sends PROXY protocol? [${ENABLE_PROXY_PROTOCOL_HINT:-n}]: " INPUT_PROXY_PROTOCOL
|
||||||
|
ENABLE_PROXY_PROTOCOL_HINT="$(sanitize_value "${INPUT_PROXY_PROTOCOL:-${ENABLE_PROXY_PROTOCOL_HINT:-n}}")"
|
||||||
|
|
||||||
|
declare -a NODE_IDS
|
||||||
|
|
||||||
|
i=1
|
||||||
|
while true; do
|
||||||
|
DEFAULT_NODE_ID=""
|
||||||
|
if [[ "$i" -le "${#V2BX_IMPORTED_NODE_IDS[@]}" ]]; then
|
||||||
|
DEFAULT_NODE_ID="${V2BX_IMPORTED_NODE_IDS[$((i-1))]}"
|
||||||
|
elif [[ "$i" -eq 1 && -n "${NODE_ID:-}" ]]; then
|
||||||
|
DEFAULT_NODE_ID="$NODE_ID"
|
||||||
|
fi
|
||||||
|
if [[ -n "$DEFAULT_NODE_ID" ]]; then
|
||||||
|
read -u 3 -p "Enter Node ID for node #$i [${DEFAULT_NODE_ID}] (type N/NO to finish): " INPUT_ID
|
||||||
|
else
|
||||||
|
read -u 3 -p "Enter Node ID for node #$i (press Enter or type N/NO to finish): " INPUT_ID
|
||||||
|
fi
|
||||||
|
CURRENT_NODE_ID="$(sanitize_value "${INPUT_ID:-$DEFAULT_NODE_ID}")"
|
||||||
|
if [[ -z "$DEFAULT_NODE_ID" && -z "$CURRENT_NODE_ID" && "${#NODE_IDS[@]}" -gt 0 ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "$CURRENT_NODE_ID" =~ ^([nN]|[nN][oO])$ ]]; then
|
||||||
|
if [[ "${#NODE_IDS[@]}" -eq 0 ]]; then
|
||||||
|
echo -e "${RED}At least one Node ID is required${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
CURRENT_NODE_ID="$(normalize_node_id_input "$CURRENT_NODE_ID")"
|
||||||
|
if [[ -z "$CURRENT_NODE_ID" ]]; then
|
||||||
|
echo -e "${RED}Node ID is required for node #$i${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
read -r -a CURRENT_NODE_ID_PARTS <<< "$CURRENT_NODE_ID"
|
||||||
|
if [[ "${#CURRENT_NODE_ID_PARTS[@]}" -eq 0 ]]; then
|
||||||
|
echo -e "${RED}Node ID is required for node #$i${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for CURRENT_NODE_ID_PART in "${CURRENT_NODE_ID_PARTS[@]}"; do
|
||||||
|
if ! [[ "$CURRENT_NODE_ID_PART" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo -e "${RED}Node ID must be a positive integer, got: ${CURRENT_NODE_ID_PART}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NODE_IDS+=("$CURRENT_NODE_ID_PART")
|
||||||
|
done
|
||||||
|
((i++))
|
||||||
|
done
|
||||||
|
|
||||||
|
NODE_COUNT=${#NODE_IDS[@]}
|
||||||
|
|
||||||
|
DNS_MODE_DEFAULT=${DNS_MODE:-udp}
|
||||||
|
read -u 3 -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE
|
||||||
|
DNS_MODE=$(echo "${INPUT_DNS_MODE:-$DNS_MODE_DEFAULT}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
case "$DNS_MODE" in
|
||||||
|
udp)
|
||||||
|
DNS_SERVER_DEFAULT=${DNS_SERVER:-1.1.1.1}
|
||||||
|
DNS_SERVER_PORT_DEFAULT=${DNS_SERVER_PORT:-53}
|
||||||
|
read -u 3 -p "Enter DNS server [${DNS_SERVER_DEFAULT}]: " INPUT_DNS_SERVER
|
||||||
|
DNS_SERVER=${INPUT_DNS_SERVER:-$DNS_SERVER_DEFAULT}
|
||||||
|
read -u 3 -p "Enter DNS server port [${DNS_SERVER_PORT_DEFAULT}]: " INPUT_DNS_SERVER_PORT
|
||||||
|
DNS_SERVER_PORT=${INPUT_DNS_SERVER_PORT:-$DNS_SERVER_PORT_DEFAULT}
|
||||||
|
if [[ -z "$DNS_SERVER" ]]; then
|
||||||
|
echo -e "${RED}DNS server is required in udp mode${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! [[ "$DNS_SERVER_PORT" =~ ^[0-9]+$ ]] || [[ "$DNS_SERVER_PORT" -lt 1 ]] || [[ "$DNS_SERVER_PORT" -gt 65535 ]]; then
|
||||||
|
echo -e "${RED}DNS server port must be an integer between 1 and 65535${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DNS_SERVER_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag": "dns-upstream",
|
||||||
|
"type": "udp",
|
||||||
|
"server": "$DNS_SERVER",
|
||||||
|
"server_port": $DNS_SERVER_PORT
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
local)
|
||||||
|
DNS_SERVER_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"tag": "dns-local",
|
||||||
|
"type": "local"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Unsupported DNS mode: $DNS_MODE. Supported values: udp, local${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Syncing system time...${NC}"
|
||||||
|
timedatectl set-ntp true || true
|
||||||
|
|
||||||
|
if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then
|
||||||
|
echo -e "${RED}All fields are required!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PANEL_URL="${PANEL_URL%/}"
|
||||||
|
|
||||||
|
SERVICE_JSON=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"type": "xboard",
|
||||||
|
"panel_url": "$PANEL_URL",
|
||||||
|
"key": "$PANEL_TOKEN",
|
||||||
|
"sync_interval": "1m",
|
||||||
|
"report_interval": "1m"
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$NODE_COUNT" -eq 1 ]]; then
|
||||||
|
SERVICE_JSON+=$(cat <<EOF
|
||||||
|
,
|
||||||
|
"node_id": ${NODE_IDS[0]}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SERVICE_JSON+=$',
|
||||||
|
"nodes": ['
|
||||||
|
for ((i=0; i<NODE_COUNT; i++)); do
|
||||||
|
NODE_BLOCK=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"node_id": ${NODE_IDS[$i]}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
NODE_BLOCK+=$'\n }'
|
||||||
|
if [[ "$i" -gt 0 ]]; then
|
||||||
|
SERVICE_JSON+=','
|
||||||
|
fi
|
||||||
|
SERVICE_JSON+=$'\n'"$NODE_BLOCK"
|
||||||
|
done
|
||||||
|
SERVICE_JSON+=$'\n ]'
|
||||||
|
fi
|
||||||
|
SERVICE_JSON+=$'\n }'
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Generating configuration...${NC}"
|
||||||
|
cat > "$CONFIG_BASE_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"log": {
|
||||||
|
"level": "info",
|
||||||
|
"timestamp": true
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"cache_file": {
|
||||||
|
"enabled": true,
|
||||||
|
"path": "$WORK_DIR/cache.db"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dns": {
|
||||||
|
"servers": [
|
||||||
|
${DNS_SERVER_JSON}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
${SERVICE_JSON}
|
||||||
|
],
|
||||||
|
"inbounds": [],
|
||||||
|
"route": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"protocol": "dns",
|
||||||
|
"action": "hijack-dns"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auto_detect_interface": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$CONFIG_OUTBOUNDS_FILE" <<EOF
|
||||||
|
{
|
||||||
|
"outbounds": [
|
||||||
|
{
|
||||||
|
"type": "direct",
|
||||||
|
"tag": "direct"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "${GREEN}Base configuration written to $CONFIG_BASE_FILE${NC}"
|
||||||
|
echo -e "${GREEN}Outbound configuration written to $CONFIG_OUTBOUNDS_FILE${NC}"
|
||||||
|
echo -e "${YELLOW}Edit $CONFIG_OUTBOUNDS_FILE when adding custom sing-box outbounds.${NC}"
|
||||||
|
|
||||||
|
if [[ "$ENABLE_PROXY_PROTOCOL_HINT" =~ ^([yY][eE][sS]|[yY]|1|true|TRUE)$ ]]; then
|
||||||
|
echo -e "${YELLOW}Proxy Protocol deployment hint enabled.${NC}"
|
||||||
|
echo -e "${YELLOW}To make real client IP reporting work, your panel node config response must include:${NC}"
|
||||||
|
echo -e "${YELLOW} \"accept_proxy_protocol\": true${NC}"
|
||||||
|
echo -e "${YELLOW}Only enable this when the upstream L4 proxy or load balancer actually sends PROXY protocol headers.${NC}"
|
||||||
|
echo -e "${YELLOW}If clients connect directly without a PROXY header, connections will fail after enabling it on the panel.${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Proxy Protocol is not expected for this deployment.${NC}"
|
||||||
|
echo -e "${YELLOW}Keep panel field \"accept_proxy_protocol\" disabled or absent unless you are using an L4 proxy/LB that sends it.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Creating systemd service...${NC}"
|
||||||
|
cat > "$SERVICE_FILE" <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=singbox service
|
||||||
|
After=network.target nss-lookup.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
|
||||||
|
ExecStart=$BINARY_PATH -D $WORK_DIR -C $CONFIG_MERGE_DIR run
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
LimitNOFILE=infinity
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE_NAME"
|
||||||
|
systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
prompt_uninstall_v2bx
|
||||||
|
|
||||||
|
echo -e "${GREEN}Service installed and started successfully.${NC}"
|
||||||
|
echo -e "${GREEN}Check status with: systemctl status ${SERVICE_NAME}${NC}"
|
||||||
|
echo -e "${GREEN}View logs with: journalctl -u ${SERVICE_NAME} -f${NC}"
|
||||||
|
echo -e "${GREEN}Panel config endpoint must control PROXY protocol via accept_proxy_protocol when needed.${NC}"
|
||||||
@@ -28,29 +28,38 @@ type ACMEExternalAccountOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type _ACMEDNS01ChallengeOptions struct {
|
type _ACMEDNS01ChallengeOptions struct {
|
||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"`
|
AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"`
|
||||||
CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"`
|
CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"`
|
||||||
ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"`
|
ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"`
|
||||||
|
TencentCloudOptions ACMEDNS01TencentCloudOptions `json:"-"`
|
||||||
|
DNSPodOptions ACMEDNS01DNSPodOptions `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions
|
type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions
|
||||||
|
|
||||||
func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) {
|
func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) {
|
||||||
|
provider := C.NormalizeACMEDNSProvider(o.Provider)
|
||||||
var v any
|
var v any
|
||||||
switch o.Provider {
|
switch provider {
|
||||||
case C.DNSProviderAliDNS:
|
case C.DNSProviderAliDNS:
|
||||||
v = o.AliDNSOptions
|
v = o.AliDNSOptions
|
||||||
case C.DNSProviderCloudflare:
|
case C.DNSProviderCloudflare:
|
||||||
v = o.CloudflareOptions
|
v = o.CloudflareOptions
|
||||||
case C.DNSProviderACMEDNS:
|
case C.DNSProviderACMEDNS:
|
||||||
v = o.ACMEDNSOptions
|
v = o.ACMEDNSOptions
|
||||||
|
case C.DNSProviderTencentCloud:
|
||||||
|
v = o.TencentCloudOptions
|
||||||
|
case C.DNSProviderDNSPod:
|
||||||
|
v = o.DNSPodOptions
|
||||||
case "":
|
case "":
|
||||||
return nil, E.New("missing provider type")
|
return nil, E.New("missing provider type")
|
||||||
default:
|
default:
|
||||||
return nil, E.New("unknown provider type: " + o.Provider)
|
return nil, E.New("unknown provider type: " + o.Provider)
|
||||||
}
|
}
|
||||||
return badjson.MarshallObjects((_ACMEDNS01ChallengeOptions)(o), v)
|
copyValue := (_ACMEDNS01ChallengeOptions)(o)
|
||||||
|
copyValue.Provider = provider
|
||||||
|
return badjson.MarshallObjects(copyValue, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
|
func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
|
||||||
@@ -58,6 +67,7 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
o.Provider = C.NormalizeACMEDNSProvider(o.Provider)
|
||||||
var v any
|
var v any
|
||||||
switch o.Provider {
|
switch o.Provider {
|
||||||
case C.DNSProviderAliDNS:
|
case C.DNSProviderAliDNS:
|
||||||
@@ -66,6 +76,10 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
|
|||||||
v = &o.CloudflareOptions
|
v = &o.CloudflareOptions
|
||||||
case C.DNSProviderACMEDNS:
|
case C.DNSProviderACMEDNS:
|
||||||
v = &o.ACMEDNSOptions
|
v = &o.ACMEDNSOptions
|
||||||
|
case C.DNSProviderTencentCloud:
|
||||||
|
v = &o.TencentCloudOptions
|
||||||
|
case C.DNSProviderDNSPod:
|
||||||
|
v = &o.DNSPodOptions
|
||||||
default:
|
default:
|
||||||
return E.New("unknown provider type: " + o.Provider)
|
return E.New("unknown provider type: " + o.Provider)
|
||||||
}
|
}
|
||||||
@@ -94,3 +108,14 @@ type ACMEDNS01ACMEDNSOptions struct {
|
|||||||
Subdomain string `json:"subdomain,omitempty"`
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
ServerURL string `json:"server_url,omitempty"`
|
ServerURL string `json:"server_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ACMEDNS01TencentCloudOptions struct {
|
||||||
|
SecretID string `json:"secret_id,omitempty"`
|
||||||
|
SecretKey string `json:"secret_key,omitempty"`
|
||||||
|
SessionToken string `json:"session_token,omitempty"`
|
||||||
|
Region string `json:"region,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ACMEDNS01DNSPodOptions struct {
|
||||||
|
APIToken string `json:"api_token,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
33
option/xboard.go
Normal file
33
option/xboard.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package option
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sagernet/sing/common/json/badoption"
|
||||||
|
)
|
||||||
|
|
||||||
|
type XBoardServiceOptions struct {
|
||||||
|
PanelURL string `json:"panel_url"`
|
||||||
|
ConfigPanelURL string `json:"config_panel_url,omitempty"`
|
||||||
|
UserPanelURL string `json:"user_panel_url,omitempty"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
NodeID int `json:"node_id"`
|
||||||
|
ConfigNodeID int `json:"config_node_id,omitempty"`
|
||||||
|
UserNodeID int `json:"user_node_id,omitempty"`
|
||||||
|
NodeType string `json:"node_type,omitempty"`
|
||||||
|
SyncInterval badoption.Duration `json:"sync_interval,omitempty"`
|
||||||
|
ReportInterval badoption.Duration `json:"report_interval,omitempty"`
|
||||||
|
Nodes []XBoardNodeOptions `json:"nodes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type XBoardNodeOptions struct {
|
||||||
|
Tag string `json:"tag,omitempty"`
|
||||||
|
PanelURL string `json:"panel_url,omitempty"`
|
||||||
|
ConfigPanelURL string `json:"config_panel_url,omitempty"`
|
||||||
|
UserPanelURL string `json:"user_panel_url,omitempty"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
NodeID int `json:"node_id,omitempty"`
|
||||||
|
ConfigNodeID int `json:"config_node_id,omitempty"`
|
||||||
|
UserNodeID int `json:"user_node_id,omitempty"`
|
||||||
|
NodeType string `json:"node_type,omitempty"`
|
||||||
|
SyncInterval badoption.Duration `json:"sync_interval,omitempty"`
|
||||||
|
ReportInterval badoption.Duration `json:"report_interval,omitempty"`
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"github.com/sagernet/sing-box/adapter"
|
||||||
"github.com/sagernet/sing-box/adapter/inbound"
|
"github.com/sagernet/sing-box/adapter/inbound"
|
||||||
@@ -35,6 +36,47 @@ type Inbound struct {
|
|||||||
logger logger.ContextLogger
|
logger logger.ContextLogger
|
||||||
listener *listener.Listener
|
listener *listener.Listener
|
||||||
service *anytls.Service
|
service *anytls.Service
|
||||||
|
options option.AnyTLSInboundOptions
|
||||||
|
tracker adapter.SSMTracker
|
||||||
|
ssmMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ adapter.ManagedSSMServer = (*Inbound)(nil)
|
||||||
|
|
||||||
|
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
|
h.tracker = tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) UpdateUsers(users []string, passwords []string, flows []string) error {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
|
|
||||||
|
paddingScheme := padding.DefaultPaddingScheme
|
||||||
|
if len(h.options.PaddingScheme) > 0 {
|
||||||
|
paddingScheme = []byte(strings.Join(h.options.PaddingScheme, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
anytlsUsers := make([]anytls.User, len(users))
|
||||||
|
for i := range users {
|
||||||
|
anytlsUsers[i] = anytls.User{
|
||||||
|
Name: users[i],
|
||||||
|
Password: passwords[i],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service, err := anytls.NewService(anytls.ServiceConfig{
|
||||||
|
Users: anytlsUsers,
|
||||||
|
PaddingScheme: paddingScheme,
|
||||||
|
Handler: (*inboundHandler)(h),
|
||||||
|
Logger: h.logger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.service = service
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) {
|
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) {
|
||||||
@@ -42,6 +84,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
|||||||
Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag),
|
Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag),
|
||||||
router: uot.NewRouter(router, logger),
|
router: uot.NewRouter(router, logger),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
|
options: options,
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.TLS != nil && options.TLS.Enabled {
|
if options.TLS != nil && options.TLS.Enabled {
|
||||||
@@ -106,7 +149,14 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
|||||||
}
|
}
|
||||||
conn = tlsConn
|
conn = tlsConn
|
||||||
}
|
}
|
||||||
err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
|
h.ssmMutex.RLock()
|
||||||
|
tracker := h.tracker
|
||||||
|
service := h.service
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
if tracker != nil {
|
||||||
|
conn = tracker.TrackConnection(conn, metadata)
|
||||||
|
}
|
||||||
|
err := service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||||
h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
|
h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"github.com/sagernet/sing-box/adapter"
|
||||||
@@ -42,6 +43,7 @@ type MultiInbound struct {
|
|||||||
service shadowsocks.MultiService[int]
|
service shadowsocks.MultiService[int]
|
||||||
users []option.ShadowsocksUser
|
users []option.ShadowsocksUser
|
||||||
tracker adapter.SSMTracker
|
tracker adapter.SSMTracker
|
||||||
|
ssmMutex sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) {
|
func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) {
|
||||||
@@ -119,10 +121,14 @@ func (h *MultiInbound) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) {
|
func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
h.tracker = tracker
|
h.tracker = tracker
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error {
|
func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string, flows []string) error {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int {
|
err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int {
|
||||||
return index
|
return index
|
||||||
}), uPSKs)
|
}), uPSKs)
|
||||||
@@ -163,7 +169,15 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
|
|||||||
if !loaded {
|
if !loaded {
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
user := h.users[userIndex].Name
|
h.ssmMutex.RLock()
|
||||||
|
if userIndex < 0 || userIndex >= len(h.users) {
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
|
userEntry := h.users[userIndex]
|
||||||
|
tracker := h.tracker
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
user := userEntry.Name
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = F.ToString(userIndex)
|
user = F.ToString(userIndex)
|
||||||
} else {
|
} else {
|
||||||
@@ -174,9 +188,8 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
|
|||||||
metadata.InboundType = h.Type()
|
metadata.InboundType = h.Type()
|
||||||
//nolint:staticcheck
|
//nolint:staticcheck
|
||||||
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
||||||
//nolint:staticcheck
|
if tracker != nil {
|
||||||
if h.tracker != nil {
|
conn = tracker.TrackConnection(conn, metadata)
|
||||||
conn = h.tracker.TrackConnection(conn, metadata)
|
|
||||||
}
|
}
|
||||||
return h.router.RouteConnection(ctx, conn, metadata)
|
return h.router.RouteConnection(ctx, conn, metadata)
|
||||||
}
|
}
|
||||||
@@ -186,7 +199,15 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon
|
|||||||
if !loaded {
|
if !loaded {
|
||||||
return os.ErrInvalid
|
return os.ErrInvalid
|
||||||
}
|
}
|
||||||
user := h.users[userIndex].Name
|
h.ssmMutex.RLock()
|
||||||
|
if userIndex < 0 || userIndex >= len(h.users) {
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
return os.ErrInvalid
|
||||||
|
}
|
||||||
|
userEntry := h.users[userIndex]
|
||||||
|
tracker := h.tracker
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
user := userEntry.Name
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = F.ToString(userIndex)
|
user = F.ToString(userIndex)
|
||||||
} else {
|
} else {
|
||||||
@@ -199,9 +220,8 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon
|
|||||||
metadata.InboundType = h.Type()
|
metadata.InboundType = h.Type()
|
||||||
//nolint:staticcheck
|
//nolint:staticcheck
|
||||||
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
||||||
//nolint:staticcheck
|
if tracker != nil {
|
||||||
if h.tracker != nil {
|
conn = tracker.TrackPacketConnection(conn, metadata)
|
||||||
conn = h.tracker.TrackPacketConnection(conn, metadata)
|
|
||||||
}
|
}
|
||||||
return h.router.RoutePacketConnection(ctx, conn, metadata)
|
return h.router.RoutePacketConnection(ctx, conn, metadata)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"github.com/sagernet/sing-box/adapter"
|
||||||
"github.com/sagernet/sing-box/adapter/inbound"
|
"github.com/sagernet/sing-box/adapter/inbound"
|
||||||
@@ -31,7 +32,10 @@ func RegisterInbound(registry *inbound.Registry) {
|
|||||||
inbound.Register[option.VLESSInboundOptions](registry, C.TypeVLESS, NewInbound)
|
inbound.Register[option.VLESSInboundOptions](registry, C.TypeVLESS, NewInbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
|
var (
|
||||||
|
_ adapter.TCPInjectableInbound = (*Inbound)(nil)
|
||||||
|
_ adapter.ManagedSSMServer = (*Inbound)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
type Inbound struct {
|
type Inbound struct {
|
||||||
inbound.Adapter
|
inbound.Adapter
|
||||||
@@ -43,6 +47,40 @@ type Inbound struct {
|
|||||||
service *vless.Service[int]
|
service *vless.Service[int]
|
||||||
tlsConfig tls.ServerConfig
|
tlsConfig tls.ServerConfig
|
||||||
transport adapter.V2RayServerTransport
|
transport adapter.V2RayServerTransport
|
||||||
|
tracker adapter.SSMTracker
|
||||||
|
ssmMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
|
h.tracker = tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
|
newUsers := make([]option.VLESSUser, len(users))
|
||||||
|
for i := range users {
|
||||||
|
flow := ""
|
||||||
|
if i < len(flows) {
|
||||||
|
flow = flows[i]
|
||||||
|
}
|
||||||
|
newUsers[i] = option.VLESSUser{
|
||||||
|
Name: users[i],
|
||||||
|
UUID: uuids[i],
|
||||||
|
Flow: flow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.users = newUsers
|
||||||
|
h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, _ option.VLESSUser) int {
|
||||||
|
return index
|
||||||
|
}), common.Map(h.users, func(it option.VLESSUser) string {
|
||||||
|
return it.UUID
|
||||||
|
}), common.Map(h.users, func(it option.VLESSUser) string {
|
||||||
|
return it.Flow
|
||||||
|
}))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) {
|
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) {
|
||||||
@@ -172,13 +210,25 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
|||||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := h.users[userIndex].Name
|
h.ssmMutex.RLock()
|
||||||
|
if userIndex < 0 || userIndex >= len(h.users) {
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userEntry := h.users[userIndex]
|
||||||
|
tracker := h.tracker
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
user := userEntry.Name
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = F.ToString(userIndex)
|
user = F.ToString(userIndex)
|
||||||
} else {
|
} else {
|
||||||
metadata.User = user
|
metadata.User = user
|
||||||
}
|
}
|
||||||
h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination)
|
h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination)
|
||||||
|
if tracker != nil {
|
||||||
|
conn = tracker.TrackConnection(conn, metadata)
|
||||||
|
}
|
||||||
h.router.RouteConnectionEx(ctx, conn, metadata, onClose)
|
h.router.RouteConnectionEx(ctx, conn, metadata, onClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +240,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
|||||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := h.users[userIndex].Name
|
h.ssmMutex.RLock()
|
||||||
|
if userIndex < 0 || userIndex >= len(h.users) {
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userEntry := h.users[userIndex]
|
||||||
|
tracker := h.tracker
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
user := userEntry.Name
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = F.ToString(userIndex)
|
user = F.ToString(userIndex)
|
||||||
} else {
|
} else {
|
||||||
@@ -203,6 +262,9 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
|||||||
} else {
|
} else {
|
||||||
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination)
|
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination)
|
||||||
}
|
}
|
||||||
|
if tracker != nil {
|
||||||
|
conn = tracker.TrackPacketConnection(conn, metadata)
|
||||||
|
}
|
||||||
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/sagernet/sing-box/adapter"
|
"github.com/sagernet/sing-box/adapter"
|
||||||
"github.com/sagernet/sing-box/adapter/inbound"
|
"github.com/sagernet/sing-box/adapter/inbound"
|
||||||
@@ -32,7 +33,10 @@ func RegisterInbound(registry *inbound.Registry) {
|
|||||||
inbound.Register[option.VMessInboundOptions](registry, C.TypeVMess, NewInbound)
|
inbound.Register[option.VMessInboundOptions](registry, C.TypeVMess, NewInbound)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ adapter.TCPInjectableInbound = (*Inbound)(nil)
|
var (
|
||||||
|
_ adapter.TCPInjectableInbound = (*Inbound)(nil)
|
||||||
|
_ adapter.ManagedSSMServer = (*Inbound)(nil)
|
||||||
|
)
|
||||||
|
|
||||||
type Inbound struct {
|
type Inbound struct {
|
||||||
inbound.Adapter
|
inbound.Adapter
|
||||||
@@ -44,6 +48,34 @@ type Inbound struct {
|
|||||||
users []option.VMessUser
|
users []option.VMessUser
|
||||||
tlsConfig tls.ServerConfig
|
tlsConfig tls.ServerConfig
|
||||||
transport adapter.V2RayServerTransport
|
transport adapter.V2RayServerTransport
|
||||||
|
tracker adapter.SSMTracker
|
||||||
|
ssmMutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
|
h.tracker = tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error {
|
||||||
|
h.ssmMutex.Lock()
|
||||||
|
defer h.ssmMutex.Unlock()
|
||||||
|
newUsers := make([]option.VMessUser, len(users))
|
||||||
|
for i := range users {
|
||||||
|
newUsers[i] = option.VMessUser{
|
||||||
|
Name: users[i],
|
||||||
|
UUID: uuids[i],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.users = newUsers
|
||||||
|
return h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, it option.VMessUser) int {
|
||||||
|
return index
|
||||||
|
}), common.Map(h.users, func(it option.VMessUser) string {
|
||||||
|
return it.UUID
|
||||||
|
}), common.Map(h.users, func(it option.VMessUser) int {
|
||||||
|
return it.AlterId
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) {
|
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) {
|
||||||
@@ -163,6 +195,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
|||||||
}
|
}
|
||||||
conn = tlsConn
|
conn = tlsConn
|
||||||
}
|
}
|
||||||
|
h.ssmMutex.RLock()
|
||||||
|
tracker := h.tracker
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
if tracker != nil {
|
||||||
|
conn = tracker.TrackConnection(conn, metadata)
|
||||||
|
}
|
||||||
err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
|
err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||||
@@ -178,7 +216,15 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
|||||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := h.users[userIndex].Name
|
h.ssmMutex.RLock()
|
||||||
|
if userIndex < 0 || userIndex >= len(h.users) {
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userEntry := h.users[userIndex]
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
user := userEntry.Name
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = F.ToString(userIndex)
|
user = F.ToString(userIndex)
|
||||||
} else {
|
} else {
|
||||||
@@ -196,7 +242,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
|||||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := h.users[userIndex].Name
|
h.ssmMutex.RLock()
|
||||||
|
if userIndex < 0 || userIndex >= len(h.users) {
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userEntry := h.users[userIndex]
|
||||||
|
tracker := h.tracker
|
||||||
|
h.ssmMutex.RUnlock()
|
||||||
|
user := userEntry.Name
|
||||||
if user == "" {
|
if user == "" {
|
||||||
user = F.ToString(userIndex)
|
user = F.ToString(userIndex)
|
||||||
} else {
|
} else {
|
||||||
@@ -209,6 +264,9 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
|||||||
} else {
|
} else {
|
||||||
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination)
|
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination)
|
||||||
}
|
}
|
||||||
|
if tracker != nil {
|
||||||
|
conn = tracker.TrackPacketConnection(conn, metadata)
|
||||||
|
}
|
||||||
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func (m *UserManager) postUpdate(updated bool) error {
|
|||||||
users = append(users, username)
|
users = append(users, username)
|
||||||
uPSKs = append(uPSKs, password)
|
uPSKs = append(uPSKs, password)
|
||||||
}
|
}
|
||||||
err := m.server.UpdateUsers(users, uPSKs)
|
err := m.server.UpdateUsers(users, uPSKs, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
112
service/xboard/multi_service.go
Normal file
112
service/xboard/multi_service.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package xboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
boxService "github.com/sagernet/sing-box/adapter/service"
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/log"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type multiNodeService struct {
|
||||||
|
boxService.Adapter
|
||||||
|
services []adapter.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMultiNodeService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) {
|
||||||
|
expanded := expandNodeOptions(options)
|
||||||
|
services := make([]adapter.Service, 0, len(expanded))
|
||||||
|
for i, node := range expanded {
|
||||||
|
nodeTag := expandedNodeTag(tag, i, options.Nodes[i], node)
|
||||||
|
service, err := newSingleService(ctx, logger, nodeTag, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create xboard node service %s: %w", nodeTag, err)
|
||||||
|
}
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
return &multiNodeService{
|
||||||
|
Adapter: boxService.NewAdapter(C.TypeXBoard, tag),
|
||||||
|
services: services,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandNodeOptions(base option.XBoardServiceOptions) []option.XBoardServiceOptions {
|
||||||
|
if len(base.Nodes) == 0 {
|
||||||
|
return []option.XBoardServiceOptions{base}
|
||||||
|
}
|
||||||
|
result := make([]option.XBoardServiceOptions, 0, len(base.Nodes))
|
||||||
|
for _, node := range base.Nodes {
|
||||||
|
child := base
|
||||||
|
child.Nodes = nil
|
||||||
|
if node.PanelURL != "" {
|
||||||
|
child.PanelURL = node.PanelURL
|
||||||
|
}
|
||||||
|
if node.ConfigPanelURL != "" {
|
||||||
|
child.ConfigPanelURL = node.ConfigPanelURL
|
||||||
|
}
|
||||||
|
if node.UserPanelURL != "" {
|
||||||
|
child.UserPanelURL = node.UserPanelURL
|
||||||
|
}
|
||||||
|
if node.Key != "" {
|
||||||
|
child.Key = node.Key
|
||||||
|
}
|
||||||
|
if node.NodeID != 0 {
|
||||||
|
child.NodeID = node.NodeID
|
||||||
|
}
|
||||||
|
if node.ConfigNodeID != 0 {
|
||||||
|
child.ConfigNodeID = node.ConfigNodeID
|
||||||
|
}
|
||||||
|
if node.UserNodeID != 0 {
|
||||||
|
child.UserNodeID = node.UserNodeID
|
||||||
|
}
|
||||||
|
if node.NodeType != "" {
|
||||||
|
child.NodeType = node.NodeType
|
||||||
|
}
|
||||||
|
if node.SyncInterval != 0 {
|
||||||
|
child.SyncInterval = node.SyncInterval
|
||||||
|
}
|
||||||
|
if node.ReportInterval != 0 {
|
||||||
|
child.ReportInterval = node.ReportInterval
|
||||||
|
}
|
||||||
|
result = append(result, child)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandedNodeTag(baseTag string, index int, entry option.XBoardNodeOptions, node option.XBoardServiceOptions) string {
|
||||||
|
nodeTag := ""
|
||||||
|
if entry.Tag != "" {
|
||||||
|
nodeTag = entry.Tag
|
||||||
|
}
|
||||||
|
if nodeTag == "" && node.NodeID != 0 {
|
||||||
|
nodeTag = fmt.Sprintf("%d", node.NodeID)
|
||||||
|
}
|
||||||
|
if nodeTag == "" {
|
||||||
|
nodeTag = fmt.Sprintf("%d", index+1)
|
||||||
|
}
|
||||||
|
if baseTag == "" {
|
||||||
|
return "xboard-" + nodeTag
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%s", baseTag, nodeTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multiNodeService) Start(stage adapter.StartStage) error {
|
||||||
|
for _, service := range s.services {
|
||||||
|
if err := service.Start(stage); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *multiNodeService) Close() error {
|
||||||
|
for i := len(s.services) - 1; i >= 0; i-- {
|
||||||
|
if err := s.services[i].Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1963
service/xboard/service.go
Normal file
1963
service/xboard/service.go
Normal file
File diff suppressed because it is too large
Load Diff
317
service/xboard/service_test.go
Normal file
317
service/xboard/service_test.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package xboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
C "github.com/sagernet/sing-box/constant"
|
||||||
|
"github.com/sagernet/sing-box/option"
|
||||||
|
"github.com/sagernet/sing/common/json/badoption"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestXUserResolveKeyPrefersPasswordFields(t *testing.T) {
|
||||||
|
user := XUser{
|
||||||
|
UUID: "uuid-value",
|
||||||
|
Passwd: "passwd-value",
|
||||||
|
Password: "password-value",
|
||||||
|
Token: "token-value",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := user.ResolveKey(); got != "passwd-value" {
|
||||||
|
t.Fatalf("ResolveKey() = %q, want %q", got, "passwd-value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXUserIdentifierPrefersUUID(t *testing.T) {
|
||||||
|
user := XUser{
|
||||||
|
ID: 7,
|
||||||
|
UUID: "uuid-value",
|
||||||
|
Email: "user@example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := user.Identifier(); got != "uuid-value" {
|
||||||
|
t.Fatalf("Identifier() = %q, want %q", got, "uuid-value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUserKeyForSS2022CombinedPassword(t *testing.T) {
|
||||||
|
service := &Service{ssServerKey: "master-key"}
|
||||||
|
user := XUser{
|
||||||
|
ID: 1,
|
||||||
|
Password: "master-key:user-key",
|
||||||
|
UUID: "uuid-value",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := service.resolveUserKey(user, true); got != "user-key" {
|
||||||
|
t.Fatalf("resolveUserKey() = %q, want %q", got, "user-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUserKeyForNonSS2022UsesResolvedKey(t *testing.T) {
|
||||||
|
service := &Service{}
|
||||||
|
user := XUser{
|
||||||
|
UUID: "uuid-value",
|
||||||
|
Passwd: "passwd-value",
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := service.resolveUserKey(user, false); got != "passwd-value" {
|
||||||
|
t.Fatalf("resolveUserKey() = %q, want %q", got, "passwd-value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestXUserIdentifierFallsBackToEmailThenID(t *testing.T) {
|
||||||
|
userWithEmail := XUser{
|
||||||
|
ID: 8,
|
||||||
|
Email: "user@example.com",
|
||||||
|
}
|
||||||
|
if got := userWithEmail.Identifier(); got != "user@example.com" {
|
||||||
|
t.Fatalf("Identifier() = %q, want %q", got, "user@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
userWithID := XUser{ID: 9}
|
||||||
|
if got := userWithID.Identifier(); got != "9" {
|
||||||
|
t.Fatalf("Identifier() = %q, want %q", got, "9")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandNodeOptions(t *testing.T) {
|
||||||
|
base := option.XBoardServiceOptions{
|
||||||
|
PanelURL: "https://panel.example",
|
||||||
|
Key: "shared-token",
|
||||||
|
NodeType: "vless",
|
||||||
|
Nodes: []option.XBoardNodeOptions{
|
||||||
|
{NodeID: 1},
|
||||||
|
{NodeID: 2, NodeType: "anytls"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := expandNodeOptions(base)
|
||||||
|
if len(nodes) != 2 {
|
||||||
|
t.Fatalf("expandNodeOptions() len = %d, want 2", len(nodes))
|
||||||
|
}
|
||||||
|
if nodes[0].NodeID != 1 || nodes[0].NodeType != "vless" {
|
||||||
|
t.Fatalf("first node = %+v", nodes[0])
|
||||||
|
}
|
||||||
|
if nodes[1].NodeID != 2 || nodes[1].NodeType != "anytls" {
|
||||||
|
t.Fatalf("second node = %+v", nodes[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandNodeOptionsMinimalInstallConfig(t *testing.T) {
|
||||||
|
base := option.XBoardServiceOptions{
|
||||||
|
PanelURL: "https://panel.example",
|
||||||
|
Key: "shared-token",
|
||||||
|
SyncInterval: 60,
|
||||||
|
ReportInterval: 60,
|
||||||
|
Nodes: []option.XBoardNodeOptions{
|
||||||
|
{NodeID: 286},
|
||||||
|
{NodeID: 774},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := expandNodeOptions(base)
|
||||||
|
if len(nodes) != 2 {
|
||||||
|
t.Fatalf("expandNodeOptions() len = %d, want 2", len(nodes))
|
||||||
|
}
|
||||||
|
for index, node := range nodes {
|
||||||
|
if node.PanelURL != base.PanelURL {
|
||||||
|
t.Fatalf("node %d PanelURL = %q, want %q", index, node.PanelURL, base.PanelURL)
|
||||||
|
}
|
||||||
|
if node.Key != base.Key {
|
||||||
|
t.Fatalf("node %d Key = %q, want %q", index, node.Key, base.Key)
|
||||||
|
}
|
||||||
|
if node.SyncInterval != base.SyncInterval {
|
||||||
|
t.Fatalf("node %d SyncInterval = %v, want %v", index, node.SyncInterval, base.SyncInterval)
|
||||||
|
}
|
||||||
|
if node.ReportInterval != base.ReportInterval {
|
||||||
|
t.Fatalf("node %d ReportInterval = %v, want %v", index, node.ReportInterval, base.ReportInterval)
|
||||||
|
}
|
||||||
|
if node.ConfigPanelURL != "" || node.UserPanelURL != "" {
|
||||||
|
t.Fatalf("node %d unexpected panel overrides: config=%q user=%q", index, node.ConfigPanelURL, node.UserPanelURL)
|
||||||
|
}
|
||||||
|
if node.ConfigNodeID != 0 || node.UserNodeID != 0 {
|
||||||
|
t.Fatalf("node %d unexpected node overrides: config=%d user=%d", index, node.ConfigNodeID, node.UserNodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nodes[0].NodeID != 286 {
|
||||||
|
t.Fatalf("first node NodeID = %d, want 286", nodes[0].NodeID)
|
||||||
|
}
|
||||||
|
if nodes[1].NodeID != 774 {
|
||||||
|
t.Fatalf("second node NodeID = %d, want 774", nodes[1].NodeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandedNodeTagFallsBackToNodeID(t *testing.T) {
|
||||||
|
tag := expandedNodeTag("", 0, option.XBoardNodeOptions{NodeID: 286}, option.XBoardServiceOptions{NodeID: 286})
|
||||||
|
if tag != "xboard-286" {
|
||||||
|
t.Fatalf("expandedNodeTag() = %q, want %q", tag, "xboard-286")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyACMEConfigFromAutoTLS(t *testing.T) {
|
||||||
|
var tlsOptions option.InboundTLSOptions
|
||||||
|
ok := applyACMEConfig(&tlsOptions, nil, true, "example.com", 8443)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("applyACMEConfig() returned false")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME == nil {
|
||||||
|
t.Fatal("ACME options not configured")
|
||||||
|
}
|
||||||
|
if len(tlsOptions.ACME.Domain) != 1 || tlsOptions.ACME.Domain[0] != "example.com" {
|
||||||
|
t.Fatalf("ACME domains = %+v", tlsOptions.ACME.Domain)
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DisableHTTPChallenge {
|
||||||
|
t.Fatal("DisableHTTPChallenge should be false for auto_tls/http mode")
|
||||||
|
}
|
||||||
|
if !tlsOptions.ACME.DisableTLSALPNChallenge {
|
||||||
|
t.Fatal("DisableTLSALPNChallenge should be true for auto_tls/http mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyACMEConfigFromDNSCertMode(t *testing.T) {
|
||||||
|
var tlsOptions option.InboundTLSOptions
|
||||||
|
ok := applyACMEConfig(&tlsOptions, &XCertConfig{
|
||||||
|
CertMode: "dns",
|
||||||
|
Domain: "example.com",
|
||||||
|
DNSProvider: "cloudflare",
|
||||||
|
DNSEnv: map[string]string{
|
||||||
|
"CF_API_TOKEN": "token",
|
||||||
|
},
|
||||||
|
}, false, "example.com", 443)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("applyACMEConfig() returned false")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil {
|
||||||
|
t.Fatal("DNS01Challenge not configured")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DNS01Challenge.Provider != "cloudflare" {
|
||||||
|
t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider)
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken != "token" {
|
||||||
|
t.Fatalf("Cloudflare API token = %q", tlsOptions.ACME.DNS01Challenge.CloudflareOptions.APIToken)
|
||||||
|
}
|
||||||
|
if !tlsOptions.ACME.DisableTLSALPNChallenge {
|
||||||
|
t.Fatal("DisableTLSALPNChallenge should be true for dns mode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyACMEConfigFromTencentCloudDNSCertMode(t *testing.T) {
|
||||||
|
var tlsOptions option.InboundTLSOptions
|
||||||
|
ok := applyACMEConfig(&tlsOptions, &XCertConfig{
|
||||||
|
CertMode: "dns",
|
||||||
|
Domain: "code.littlediary.cn",
|
||||||
|
DNSProvider: "tencentcloud",
|
||||||
|
DNSEnv: map[string]string{
|
||||||
|
"TENCENTCLOUD_SECRET_ID": "sid",
|
||||||
|
"TENCENTCLOUD_SECRET_KEY": "skey",
|
||||||
|
},
|
||||||
|
}, false, "code.littlediary.cn", 45365)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("applyACMEConfig() returned false")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil {
|
||||||
|
t.Fatal("DNS01Challenge not configured")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DNS01Challenge.Provider != C.DNSProviderTencentCloud {
|
||||||
|
t.Fatalf("DNS provider = %q", tlsOptions.ACME.DNS01Challenge.Provider)
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretID != "sid" {
|
||||||
|
t.Fatalf("TencentCloud SecretID = %q", tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretID)
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretKey != "skey" {
|
||||||
|
t.Fatalf("TencentCloud SecretKey = %q", tlsOptions.ACME.DNS01Challenge.TencentCloudOptions.SecretKey)
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.AlternativeTLSPort != 45365 {
|
||||||
|
t.Fatalf("AlternativeTLSPort = %d, want 45365", tlsOptions.ACME.AlternativeTLSPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyACMEConfigFromDNSPodAliasWithTencentCredentials(t *testing.T) {
|
||||||
|
var tlsOptions option.InboundTLSOptions
|
||||||
|
ok := applyACMEConfig(&tlsOptions, &XCertConfig{
|
||||||
|
CertMode: "dns",
|
||||||
|
Domain: "code.littlediary.cn",
|
||||||
|
DNSProvider: "dnspod",
|
||||||
|
DNSEnv: map[string]string{
|
||||||
|
"TENCENTCLOUD_SECRET_ID": "sid",
|
||||||
|
"TENCENTCLOUD_SECRET_KEY": "skey",
|
||||||
|
},
|
||||||
|
}, false, "code.littlediary.cn", 443)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("applyACMEConfig() returned false")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME == nil || tlsOptions.ACME.DNS01Challenge == nil {
|
||||||
|
t.Fatal("DNS01Challenge not configured")
|
||||||
|
}
|
||||||
|
if tlsOptions.ACME.DNS01Challenge.Provider != C.DNSProviderTencentCloud {
|
||||||
|
t.Fatalf("DNS provider = %q, want %q", tlsOptions.ACME.DNS01Challenge.Provider, C.DNSProviderTencentCloud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergedTLSSettingsUsesTopLevelServerName(t *testing.T) {
|
||||||
|
tlsSettings := mergedTLSSettings(XInnerConfig{}, &XNodeConfig{
|
||||||
|
ServerName: "code.littlediary.cn",
|
||||||
|
})
|
||||||
|
if tlsSettings == nil {
|
||||||
|
t.Fatal("mergedTLSSettings() returned nil")
|
||||||
|
}
|
||||||
|
if tlsSettings.ServerName != "code.littlediary.cn" {
|
||||||
|
t.Fatalf("ServerName = %q, want %q", tlsSettings.ServerName, "code.littlediary.cn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasUsableServerTLS(t *testing.T) {
|
||||||
|
if hasUsableServerTLS(option.InboundTLSOptions{}) {
|
||||||
|
t.Fatal("empty TLS options should not be usable")
|
||||||
|
}
|
||||||
|
if !hasUsableServerTLS(option.InboundTLSOptions{
|
||||||
|
CertificatePath: "cert.pem",
|
||||||
|
KeyPath: "key.pem",
|
||||||
|
}) {
|
||||||
|
t.Fatal("file certificate should be usable")
|
||||||
|
}
|
||||||
|
if !hasUsableServerTLS(option.InboundTLSOptions{
|
||||||
|
ACME: &option.InboundACMEOptions{
|
||||||
|
Domain: badoption.Listable[string]{"example.com"},
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
t.Fatal("ACME certificate should be usable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildInboundMultiplex(t *testing.T) {
|
||||||
|
config := &XMultiplexConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Padding: true,
|
||||||
|
Brutal: &XBrutalConfig{
|
||||||
|
Enabled: true,
|
||||||
|
UpMbps: 100,
|
||||||
|
DownMbps: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := buildInboundMultiplex(config)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("buildInboundMultiplex() returned nil")
|
||||||
|
}
|
||||||
|
if !got.Enabled || !got.Padding {
|
||||||
|
t.Fatalf("buildInboundMultiplex() = %+v", got)
|
||||||
|
}
|
||||||
|
if got.Brutal == nil || !got.Brutal.Enabled || got.Brutal.UpMbps != 100 || got.Brutal.DownMbps != 200 {
|
||||||
|
t.Fatalf("buildInboundMultiplex() brutal = %+v", got.Brutal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePanelNodeType(t *testing.T) {
|
||||||
|
tests := map[string]string{
|
||||||
|
"v2ray": "vmess",
|
||||||
|
"hysteria2": "hysteria",
|
||||||
|
"vless": "vless",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
for input, want := range tests {
|
||||||
|
if got := normalizePanelNodeType(input); got != want {
|
||||||
|
t.Fatalf("normalizePanelNodeType(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
99
service/xboard/tracker.go
Normal file
99
service/xboard/tracker.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package xboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sagernet/sing-box/adapter"
|
||||||
|
"github.com/sagernet/sing-box/service/ssmapi"
|
||||||
|
N "github.com/sagernet/sing/common/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
const aliveIPRetention = 5 * time.Minute
|
||||||
|
|
||||||
|
var _ adapter.SSMTracker = (*xboardTracker)(nil)
|
||||||
|
|
||||||
|
type xboardTracker struct {
|
||||||
|
service *Service
|
||||||
|
traffic *ssmapi.TrafficManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func newXboardTracker(service *Service) *xboardTracker {
|
||||||
|
return &xboardTracker{
|
||||||
|
service: service,
|
||||||
|
traffic: ssmapi.NewTrafficManager(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xboardTracker) TrafficManager() *ssmapi.TrafficManager {
|
||||||
|
return t.traffic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xboardTracker) TrackConnection(conn net.Conn, metadata adapter.InboundContext) net.Conn {
|
||||||
|
t.service.recordAliveIP(metadata)
|
||||||
|
return t.traffic.TrackConnection(conn, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *xboardTracker) TrackPacketConnection(conn N.PacketConn, metadata adapter.InboundContext) N.PacketConn {
|
||||||
|
t.service.recordAliveIP(metadata)
|
||||||
|
return t.traffic.TrackPacketConnection(conn, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordAliveIP(metadata adapter.InboundContext) {
|
||||||
|
if metadata.User == "" || !metadata.Source.IsValid() || !metadata.Source.Addr.IsValid() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIP := metadata.Source.Addr.Unmap().String()
|
||||||
|
if clientIP == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.access.Lock()
|
||||||
|
defer s.access.Unlock()
|
||||||
|
|
||||||
|
ipSet, exists := s.aliveUsers[metadata.User]
|
||||||
|
if !exists {
|
||||||
|
ipSet = make(map[string]time.Time)
|
||||||
|
s.aliveUsers[metadata.User] = ipSet
|
||||||
|
}
|
||||||
|
ipSet[clientIP] = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) buildAlivePayload() map[string][]string {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.access.Lock()
|
||||||
|
defer s.access.Unlock()
|
||||||
|
|
||||||
|
payload := make(map[string][]string)
|
||||||
|
for userName, ipSet := range s.aliveUsers {
|
||||||
|
userMeta, exists := s.localUsers[userName]
|
||||||
|
if !exists || userMeta.ID == 0 {
|
||||||
|
delete(s.aliveUsers, userName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
activeIPs := make([]string, 0, len(ipSet))
|
||||||
|
for ip, lastSeen := range ipSet {
|
||||||
|
if now.Sub(lastSeen) > aliveIPRetention {
|
||||||
|
delete(ipSet, ip)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
activeIPs = append(activeIPs, ip)
|
||||||
|
}
|
||||||
|
if len(ipSet) == 0 {
|
||||||
|
delete(s.aliveUsers, userName)
|
||||||
|
}
|
||||||
|
if len(activeIPs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(activeIPs)
|
||||||
|
payload[strconv.Itoa(userMeta.ID)] = activeIPs
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user