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">
|
||||
<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>
|
||||
# sing-box Xboard Fork
|
||||
|
||||
---
|
||||
这是一个基于上游 [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
|
||||
|
||||
```
|
||||
```text
|
||||
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type ManagedSSMServer interface {
|
||||
Inbound
|
||||
SetTracker(tracker SSMTracker)
|
||||
UpdateUsers(users []string, uPSKs []string) error
|
||||
UpdateUsers(users []string, uPSKs []string, flows []string) error
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
boxdnspod "github.com/sagernet/sing-box/common/dnspod"
|
||||
C "github.com/sagernet/sing-box/constant"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/libdns/acmedns"
|
||||
"github.com/libdns/alidns"
|
||||
"github.com/libdns/cloudflare"
|
||||
"github.com/libdns/tencentcloud"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"go.uber.org/zap"
|
||||
"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 != "" {
|
||||
var solver certmagic.DNS01Solver
|
||||
switch dnsOptions.Provider {
|
||||
switch C.NormalizeACMEDNSProvider(dnsOptions.Provider) {
|
||||
case C.DNSProviderAliDNS:
|
||||
solver.DNSProvider = &alidns.Provider{
|
||||
CredentialInfo: alidns.CredentialInfo{
|
||||
@@ -127,6 +129,17 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
|
||||
APIToken: dnsOptions.CloudflareOptions.APIToken,
|
||||
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:
|
||||
solver.DNSProvider = &acmedns.Provider{
|
||||
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
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
DefaultDNSTTL = 600
|
||||
)
|
||||
@@ -31,7 +33,28 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
DNSProviderAliDNS = "alidns"
|
||||
DNSProviderCloudflare = "cloudflare"
|
||||
DNSProviderACMEDNS = "acmedns"
|
||||
DNSProviderAliDNS = "alidns"
|
||||
DNSProviderCloudflare = "cloudflare"
|
||||
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"
|
||||
TypeOCM = "ocm"
|
||||
TypeOOMKiller = "oom-killer"
|
||||
TypeXBoard = "xboard"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
||||
github.com/libdns/acmedns v0.5.0
|
||||
github.com/libdns/alidns v1.0.6
|
||||
github.com/libdns/cloudflare v0.2.2
|
||||
github.com/libdns/tencentcloud v1.4.3
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible
|
||||
github.com/metacubex/utls v1.8.4
|
||||
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/service/resolved"
|
||||
"github.com/sagernet/sing-box/service/ssmapi"
|
||||
"github.com/sagernet/sing-box/service/xboard"
|
||||
E "github.com/sagernet/sing/common/exceptions"
|
||||
)
|
||||
|
||||
@@ -130,6 +131,7 @@ func ServiceRegistry() *service.Registry {
|
||||
|
||||
resolved.RegisterService(registry)
|
||||
ssmapi.RegisterService(registry)
|
||||
xboard.RegisterService(registry)
|
||||
|
||||
registerDERPService(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 {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"`
|
||||
CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"`
|
||||
ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"`
|
||||
CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"`
|
||||
ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"`
|
||||
TencentCloudOptions ACMEDNS01TencentCloudOptions `json:"-"`
|
||||
DNSPodOptions ACMEDNS01DNSPodOptions `json:"-"`
|
||||
}
|
||||
|
||||
type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions
|
||||
|
||||
func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) {
|
||||
provider := C.NormalizeACMEDNSProvider(o.Provider)
|
||||
var v any
|
||||
switch o.Provider {
|
||||
switch provider {
|
||||
case C.DNSProviderAliDNS:
|
||||
v = o.AliDNSOptions
|
||||
case C.DNSProviderCloudflare:
|
||||
v = o.CloudflareOptions
|
||||
case C.DNSProviderACMEDNS:
|
||||
v = o.ACMEDNSOptions
|
||||
case C.DNSProviderTencentCloud:
|
||||
v = o.TencentCloudOptions
|
||||
case C.DNSProviderDNSPod:
|
||||
v = o.DNSPodOptions
|
||||
case "":
|
||||
return nil, E.New("missing provider type")
|
||||
default:
|
||||
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 {
|
||||
@@ -58,6 +67,7 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Provider = C.NormalizeACMEDNSProvider(o.Provider)
|
||||
var v any
|
||||
switch o.Provider {
|
||||
case C.DNSProviderAliDNS:
|
||||
@@ -66,6 +76,10 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
|
||||
v = &o.CloudflareOptions
|
||||
case C.DNSProviderACMEDNS:
|
||||
v = &o.ACMEDNSOptions
|
||||
case C.DNSProviderTencentCloud:
|
||||
v = &o.TencentCloudOptions
|
||||
case C.DNSProviderDNSPod:
|
||||
v = &o.DNSPodOptions
|
||||
default:
|
||||
return E.New("unknown provider type: " + o.Provider)
|
||||
}
|
||||
@@ -94,3 +108,14 @@ type ACMEDNS01ACMEDNSOptions struct {
|
||||
Subdomain string `json:"subdomain,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"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/inbound"
|
||||
@@ -35,6 +36,47 @@ type Inbound struct {
|
||||
logger logger.ContextLogger
|
||||
listener *listener.Listener
|
||||
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) {
|
||||
@@ -42,6 +84,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
|
||||
Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag),
|
||||
router: uot.NewRouter(router, logger),
|
||||
logger: logger,
|
||||
options: options,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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 {
|
||||
N.CloseOnHandshakeFailure(conn, onClose, err)
|
||||
h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source))
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
@@ -42,6 +43,7 @@ type MultiInbound struct {
|
||||
service shadowsocks.MultiService[int]
|
||||
users []option.ShadowsocksUser
|
||||
tracker adapter.SSMTracker
|
||||
ssmMutex sync.RWMutex
|
||||
}
|
||||
|
||||
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) {
|
||||
h.ssmMutex.Lock()
|
||||
defer h.ssmMutex.Unlock()
|
||||
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 {
|
||||
return index
|
||||
}), uPSKs)
|
||||
@@ -163,7 +169,15 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
|
||||
if !loaded {
|
||||
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 == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
@@ -174,9 +188,8 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
|
||||
metadata.InboundType = h.Type()
|
||||
//nolint:staticcheck
|
||||
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
||||
//nolint:staticcheck
|
||||
if h.tracker != nil {
|
||||
conn = h.tracker.TrackConnection(conn, metadata)
|
||||
if tracker != nil {
|
||||
conn = tracker.TrackConnection(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 {
|
||||
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 == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
@@ -199,9 +220,8 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon
|
||||
metadata.InboundType = h.Type()
|
||||
//nolint:staticcheck
|
||||
metadata.InboundDetour = h.listener.ListenOptions().Detour
|
||||
//nolint:staticcheck
|
||||
if h.tracker != nil {
|
||||
conn = h.tracker.TrackPacketConnection(conn, metadata)
|
||||
if tracker != nil {
|
||||
conn = tracker.TrackPacketConnection(conn, metadata)
|
||||
}
|
||||
return h.router.RoutePacketConnection(ctx, conn, metadata)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/inbound"
|
||||
@@ -31,7 +32,10 @@ func RegisterInbound(registry *inbound.Registry) {
|
||||
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 {
|
||||
inbound.Adapter
|
||||
@@ -43,6 +47,40 @@ type Inbound struct {
|
||||
service *vless.Service[int]
|
||||
tlsConfig tls.ServerConfig
|
||||
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) {
|
||||
@@ -172,13 +210,25 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||
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 == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
metadata.User = user
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -190,7 +240,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||
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 == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
@@ -203,6 +262,9 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/adapter/inbound"
|
||||
@@ -32,7 +33,10 @@ func RegisterInbound(registry *inbound.Registry) {
|
||||
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 {
|
||||
inbound.Adapter
|
||||
@@ -44,6 +48,34 @@ type Inbound struct {
|
||||
users []option.VMessUser
|
||||
tlsConfig tls.ServerConfig
|
||||
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) {
|
||||
@@ -163,6 +195,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
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 == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
@@ -196,7 +242,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
||||
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
|
||||
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 == "" {
|
||||
user = F.ToString(userIndex)
|
||||
} else {
|
||||
@@ -209,6 +264,9 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *UserManager) postUpdate(updated bool) error {
|
||||
users = append(users, username)
|
||||
uPSKs = append(uPSKs, password)
|
||||
}
|
||||
err := m.server.UpdateUsers(users, uPSKs)
|
||||
err := m.server.UpdateUsers(users, uPSKs, nil)
|
||||
if err != nil {
|
||||
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