Reapply SingboxForPanel integration on upstream stable

This commit is contained in:
CN-JS-HuiBai
2026-04-16 10:29:41 +08:00
parent d5adb54bc6
commit 66c252d6ef
29 changed files with 5280 additions and 41 deletions

436
README.md
View File

@@ -1,24 +1,436 @@
> Sponsored by [Warp](https://go.warp.dev/sing-box), built for coding with multiple AI agents > Sponsored by CodeX
<a href="https://go.warp.dev/sing-box"> # sing-box Xboard Fork
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/raw/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
</a>
--- 这是一个基于上游 [SagerNet/sing-box](https://github.com/SagerNet/sing-box) 的定制分支,主要面向 Xboard / UniProxy 面板联动部署场景。
# sing-box 它保留了上游 `sing-box` 内核能力,同时补充了:
The universal proxy platform. - Xboard 动态入站服务
- 多节点托管
- 面板协议自动识别
- VLESS / REALITY 面板字段映射
- Shadowsocks 2022 用户同步与密钥处理
- AnyTLS / Trojan / Hysteria / TUIC 的本地 TLS / ACME 支持
- 安装脚本与分离式配置模板
- PROXY protocol 客户端真实 IP 传递
[![Packaging status](https://repology.org/badge/vertical-allrepos/sing-box.svg)](https://repology.org/project/sing-box/versions) 如果你要查看官方功能基线、通用配置文档、原始内核行为,请优先参考上游:
## Documentation - 上游仓库https://github.com/SagerNet/sing-box
- 上游文档https://sing-box.sagernet.org
https://sing-box.sagernet.org ## 适用场景
这个仓库更适合以下用途:
- 从 Xboard / UniProxy 面板拉取节点配置
- 从面板拉取用户并动态更新
- 单机运行多个节点
- 给面板型机场节点准备安装脚本
- 对接面板下发的 `protocol``cert_config``accept_proxy_protocol`、REALITY 字段等
## 主要特性
- `services.xboard` 动态服务
- 支持单节点和多节点
- 协议优先从面板返回的 `protocol` 识别
- 支持面板回包控制 `accept_proxy_protocol`
- 支持 VLESS REALITY 的 `server_name``public_key``private_key``short_id`
- 支持 ACME
- `auto_tls`
- `cert_mode = http`
- `cert_mode = dns`
- 当前已支持的 ACME DNS provider
- `cloudflare`
- `alidns`
- `tencentcloud`
- `dnspod`
- `acmedns`
- 安装脚本默认生成:
- `/etc/sing-box/config.d/10-base.json`
- `/etc/sing-box/config.d/20-outbounds.json`
- 安装后的服务名为:
- `singbox.service`
## 仓库内关键文件
- [install.sh](./install.sh)
Linux 安装脚本
- [option/xboard.go](./option/xboard.go)
`services.xboard` 配置结构
- [service/xboard/service.go](./service/xboard/service.go)
Xboard 动态服务实现
- [configs](./configs)
配置示例目录
## 快速开始
### 1. 编译并安装
在 Linux 服务器上进入仓库目录:
```bash
curl -fsSL https://s3.cloudyun.top/downloads/singbox/install.sh | bash
```
`install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。
脚本会做这些事情:
1. 检查 Go 环境
2. 编译当前仓库代码
3. 生成分离配置
4. 创建 `singbox.service`
5. 启动服务
### 2. 安装过程会询问的内容
- `Panel URL`
- `Panel Token`
- 一个或多个 `Node ID`
- DNS 模式:
- `udp`
- `local`
- 当前节点前面是否有发送 PROXY protocol 的四层代理
### 3. 多节点输入规则
- 安装脚本会持续要求输入 `Node ID`
- 输入 `NO` 才结束
- 至少要有一个节点
## 运行与管理
查看状态:
```bash
systemctl status singbox
```
查看日志:
```bash
journalctl -u singbox -f
```
重启服务:
```bash
systemctl restart singbox
```
手动运行:
```bash
sing-box -D /var/lib/sing-box -C /etc/sing-box/config.d run
```
## 推荐配置结构
建议把配置拆成两部分。
### `10-base.json`
放这些内容:
- 日志
- DNS
- `services`
- 基础路由规则
### `20-outbounds.json`
放这些内容:
- 全部出站
- 出站标签
- 你自己的分流依赖
这样更方便:
- 面板动态服务和你的自定义出站分离
- 安装脚本生成的基础配置不容易被误改
- 调整出站时不会影响 Xboard 服务主体
示例已放在:
- [configs/10-base.single-node.json](./configs/10-base.single-node.json)
- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json)
- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json)
## `services.xboard` 配置说明
配置结构定义见 [option/xboard.go](./option/xboard.go)。
### 最小单节点示例
```json
{
"type": "xboard",
"panel_url": "https://panel.example.com",
"key": "replace-with-node-token",
"sync_interval": "1m",
"report_interval": "1m",
"node_id": 286
}
```
### 多节点示例
```json
{
"type": "xboard",
"panel_url": "https://panel.example.com",
"key": "replace-with-node-token",
"sync_interval": "1m",
"report_interval": "1m",
"nodes": [
{
"node_id": 286
},
{
"node_id": 774
}
]
}
```
### 当前推荐做法
- 只传 `panel_url`
- 只传 `key`
- 只传 `node_id``nodes`
下面这些字段仍然保留兼容,但常规部署一般不需要:
- `config_panel_url`
- `user_panel_url`
- `config_node_id`
- `user_node_id`
- `node_type`
说明:
- 当前逻辑会优先从面板回包中的 `protocol` 自动识别协议
- `node_type` 更适合做历史兼容,不建议再依赖它做主配置
## 面板配置回包约定
### 1. 协议识别
推荐面板显式返回:
```json
{
"protocol": "vless"
}
```
当前服务会按如下顺序识别协议:
1. `protocol`
2. `node_type`
3. 如果是 Shadowsocks 且存在 `cipher`,则回退识别为 `shadowsocks`
### 2. 监听地址与端口
推荐面板返回:
```json
{
"listen_ip": "0.0.0.0",
"server_port": 443
}
```
### 3. PROXY protocol
如果前面有四层代理,并且它会发送 PROXY protocol 头,需要面板返回:
```json
{
"accept_proxy_protocol": true
}
```
如果用户是直连节点,不要开启这个字段,否则连接会失败。
### 4. VLESS REALITY
当前支持从面板获取这些字段:
- `tls_settings.server_name`
- `tls_settings.public_key`
- `tls_settings.private_key`
- `tls_settings.short_id`
- 顶层 `server_name`
- 顶层 `public_key`
- 顶层 `private_key`
- 顶层 `short_id`
示例见:
- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json)
### 5. Shadowsocks 2022
推荐面板返回:
- `protocol: "shadowsocks"`
- `cipher`
- `server_key`
示例见:
- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json)
### 6. AnyTLS / Trojan / Hysteria / TUIC 的证书要求
这些协议通常要求本地具备可用 TLS 证书。当前支持:
- 文件证书
- 证书内容直传
- ACME 自动签发
如果面板没有下发可用证书,也没有有效 ACME 配置,常见报错是:
```text
Xboard setup error: missing certificate
```
示例见:
- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json)
## ACME 说明
当前支持:
- `auto_tls: true`
- `cert_mode: "http"`
- `cert_mode: "dns"`
### DNS-01 provider
已支持:
- `cloudflare`
- `alidns`
- `tencentcloud`
- `dnspod`
- `acmedns`
### DNSPod 说明
仓库已经内置 DNSPod provider 适配,不再依赖旧版 `github.com/libdns/dnspod` 的接口兼容。
你可以从面板下发:
```json
{
"cert_config": {
"cert_mode": "dns",
"dns_provider": "dnspod",
"dns_env": {
"DNSPOD_TOKEN": "id,token"
}
}
}
```
也可以下发腾讯云凭据:
```json
{
"cert_config": {
"cert_mode": "dns",
"dns_provider": "tencentcloud",
"dns_env": {
"TENCENTCLOUD_SECRET_ID": "xxx",
"TENCENTCLOUD_SECRET_KEY": "xxx"
}
}
}
```
## 配置示例目录
[configs](./configs) 目录中已经提供了这些示例:
- [configs/10-base.single-node.json](./configs/10-base.single-node.json)
单节点基础配置
- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json)
多节点基础配置
- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json)
出站配置模板
- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json)
VLESS REALITY 面板回包
- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json)
Shadowsocks 2022 面板回包
- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json)
AnyTLS + ACME DNS 验证面板回包
## 常见问题
### `unsupported protocol: empty`
原因通常是:
- 面板没有返回 `protocol`
- 兼容字段 `node_type` 也为空
建议:
- 面板显式返回顶层 `protocol`
### `Xboard setup error: missing certificate`
原因通常是:
- AnyTLS / Trojan / Hysteria / TUIC 需要证书
- 面板没有下发证书文件、证书内容或 ACME 参数
### `TLS handshake: REALITY: processed invalid connection`
多数情况下表示客户端参数和当前 REALITY 节点配置不匹配,例如:
- `server_name` 错误
- `public_key` 错误
- `short_id` 错误
- 客户端实际上不是按 REALITY 模式连入
### `Server does not exist`
通常是:
- 面板里不存在该 `node_id`
- `token` 不匹配
- 拉取配置或拉取用户的节点 ID 写错
### 真实 IP 没有正确获取
请确认:
1. 前置代理确实发送了 PROXY protocol
2. 面板确实下发了 `accept_proxy_protocol: true`
否则服务只能看到上游代理 IP。
## 开发验证
近期已验证通过的命令:
```bash
go test ./common/dnspod ./common/tls ./service/acme ./service/xboard
go build -trimpath -tags 'with_quic,with_utls,with_clash_api,with_gvisor,with_acme' ./cmd/sing-box
```
如果你改动了 Xboard、协议映射、ACME 或证书相关逻辑,建议至少执行一次以上命令。
## License ## License
``` ```text
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu> Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
@@ -28,7 +440,7 @@ the Free Software Foundation, either version 3 of the License, or
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License

View File

@@ -9,7 +9,7 @@ import (
type ManagedSSMServer interface { type ManagedSSMServer interface {
Inbound Inbound
SetTracker(tracker SSMTracker) SetTracker(tracker SSMTracker)
UpdateUsers(users []string, uPSKs []string) error UpdateUsers(users []string, uPSKs []string, flows []string) error
} }
type SSMTracker interface { type SSMTracker interface {

401
building-install.sh Normal file
View 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
View 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
View 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
View 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
}

View File

@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
boxdnspod "github.com/sagernet/sing-box/common/dnspod"
C "github.com/sagernet/sing-box/constant" C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
@@ -17,6 +18,7 @@ import (
"github.com/libdns/acmedns" "github.com/libdns/acmedns"
"github.com/libdns/alidns" "github.com/libdns/alidns"
"github.com/libdns/cloudflare" "github.com/libdns/cloudflare"
"github.com/libdns/tencentcloud"
"github.com/mholt/acmez/v3/acme" "github.com/mholt/acmez/v3/acme"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
@@ -112,7 +114,7 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
} }
if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" { if dnsOptions := options.DNS01Challenge; dnsOptions != nil && dnsOptions.Provider != "" {
var solver certmagic.DNS01Solver var solver certmagic.DNS01Solver
switch dnsOptions.Provider { switch C.NormalizeACMEDNSProvider(dnsOptions.Provider) {
case C.DNSProviderAliDNS: case C.DNSProviderAliDNS:
solver.DNSProvider = &alidns.Provider{ solver.DNSProvider = &alidns.Provider{
CredentialInfo: alidns.CredentialInfo{ CredentialInfo: alidns.CredentialInfo{
@@ -127,6 +129,17 @@ func startACME(ctx context.Context, logger logger.Logger, options option.Inbound
APIToken: dnsOptions.CloudflareOptions.APIToken, APIToken: dnsOptions.CloudflareOptions.APIToken,
ZoneToken: dnsOptions.CloudflareOptions.ZoneToken, ZoneToken: dnsOptions.CloudflareOptions.ZoneToken,
} }
case C.DNSProviderTencentCloud:
solver.DNSProvider = &tencentcloud.Provider{
SecretId: dnsOptions.TencentCloudOptions.SecretID,
SecretKey: dnsOptions.TencentCloudOptions.SecretKey,
SessionToken: dnsOptions.TencentCloudOptions.SessionToken,
Region: dnsOptions.TencentCloudOptions.Region,
}
case C.DNSProviderDNSPod:
solver.DNSProvider = &boxdnspod.Provider{
APIToken: dnsOptions.DNSPodOptions.APIToken,
}
case C.DNSProviderACMEDNS: case C.DNSProviderACMEDNS:
solver.DNSProvider = &acmedns.Provider{ solver.DNSProvider = &acmedns.Provider{
Username: dnsOptions.ACMEDNSOptions.Username, Username: dnsOptions.ACMEDNSOptions.Username,

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,12 @@
{
"outbounds": [
{
"type": "direct",
"tag": "direct"
},
{
"type": "block",
"tag": "block"
}
]
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -1,5 +1,7 @@
package constant package constant
import "strings"
const ( const (
DefaultDNSTTL = 600 DefaultDNSTTL = 600
) )
@@ -31,7 +33,28 @@ const (
) )
const ( const (
DNSProviderAliDNS = "alidns" DNSProviderAliDNS = "alidns"
DNSProviderCloudflare = "cloudflare" DNSProviderCloudflare = "cloudflare"
DNSProviderACMEDNS = "acmedns" DNSProviderACMEDNS = "acmedns"
DNSProviderTencentCloud = "tencentcloud"
DNSProviderDNSPod = "dnspod"
) )
func NormalizeACMEDNSProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "", DNSProviderAliDNS, DNSProviderCloudflare, DNSProviderACMEDNS:
return strings.ToLower(strings.TrimSpace(provider))
case "aliyun":
return DNSProviderAliDNS
case "cf":
return DNSProviderCloudflare
case "acme-dns":
return DNSProviderACMEDNS
case "tencent", "tencentcloud", "dnspod-tencentcloud", "qcloud":
return DNSProviderTencentCloud
case "dnspod":
return DNSProviderDNSPod
default:
return strings.ToLower(strings.TrimSpace(provider))
}
}

View File

@@ -31,6 +31,7 @@ const (
TypeCCM = "ccm" TypeCCM = "ccm"
TypeOCM = "ocm" TypeOCM = "ocm"
TypeOOMKiller = "oom-killer" TypeOOMKiller = "oom-killer"
TypeXBoard = "xboard"
) )
const ( const (

1
go.mod
View File

@@ -18,6 +18,7 @@ require (
github.com/libdns/acmedns v0.5.0 github.com/libdns/acmedns v0.5.0
github.com/libdns/alidns v1.0.6 github.com/libdns/alidns v1.0.6
github.com/libdns/cloudflare v0.2.2 github.com/libdns/cloudflare v0.2.2
github.com/libdns/tencentcloud v1.4.3
github.com/logrusorgru/aurora v2.0.3+incompatible github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/metacubex/utls v1.8.4 github.com/metacubex/utls v1.8.4
github.com/mholt/acmez/v3 v3.1.6 github.com/mholt/acmez/v3 v3.1.6

View File

@@ -36,6 +36,7 @@ import (
"github.com/sagernet/sing-box/protocol/vmess" "github.com/sagernet/sing-box/protocol/vmess"
"github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/resolved"
"github.com/sagernet/sing-box/service/ssmapi" "github.com/sagernet/sing-box/service/ssmapi"
"github.com/sagernet/sing-box/service/xboard"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
) )
@@ -130,6 +131,7 @@ func ServiceRegistry() *service.Registry {
resolved.RegisterService(registry) resolved.RegisterService(registry)
ssmapi.RegisterService(registry) ssmapi.RegisterService(registry)
xboard.RegisterService(registry)
registerDERPService(registry) registerDERPService(registry)
registerCCMService(registry) registerCCMService(registry)

576
install.sh Normal file
View 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}"

View File

@@ -28,29 +28,38 @@ type ACMEExternalAccountOptions struct {
} }
type _ACMEDNS01ChallengeOptions struct { type _ACMEDNS01ChallengeOptions struct {
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"` AliDNSOptions ACMEDNS01AliDNSOptions `json:"-"`
CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"` CloudflareOptions ACMEDNS01CloudflareOptions `json:"-"`
ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"` ACMEDNSOptions ACMEDNS01ACMEDNSOptions `json:"-"`
TencentCloudOptions ACMEDNS01TencentCloudOptions `json:"-"`
DNSPodOptions ACMEDNS01DNSPodOptions `json:"-"`
} }
type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions type ACMEDNS01ChallengeOptions _ACMEDNS01ChallengeOptions
func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) { func (o ACMEDNS01ChallengeOptions) MarshalJSON() ([]byte, error) {
provider := C.NormalizeACMEDNSProvider(o.Provider)
var v any var v any
switch o.Provider { switch provider {
case C.DNSProviderAliDNS: case C.DNSProviderAliDNS:
v = o.AliDNSOptions v = o.AliDNSOptions
case C.DNSProviderCloudflare: case C.DNSProviderCloudflare:
v = o.CloudflareOptions v = o.CloudflareOptions
case C.DNSProviderACMEDNS: case C.DNSProviderACMEDNS:
v = o.ACMEDNSOptions v = o.ACMEDNSOptions
case C.DNSProviderTencentCloud:
v = o.TencentCloudOptions
case C.DNSProviderDNSPod:
v = o.DNSPodOptions
case "": case "":
return nil, E.New("missing provider type") return nil, E.New("missing provider type")
default: default:
return nil, E.New("unknown provider type: " + o.Provider) return nil, E.New("unknown provider type: " + o.Provider)
} }
return badjson.MarshallObjects((_ACMEDNS01ChallengeOptions)(o), v) copyValue := (_ACMEDNS01ChallengeOptions)(o)
copyValue.Provider = provider
return badjson.MarshallObjects(copyValue, v)
} }
func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error { func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
@@ -58,6 +67,7 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
if err != nil { if err != nil {
return err return err
} }
o.Provider = C.NormalizeACMEDNSProvider(o.Provider)
var v any var v any
switch o.Provider { switch o.Provider {
case C.DNSProviderAliDNS: case C.DNSProviderAliDNS:
@@ -66,6 +76,10 @@ func (o *ACMEDNS01ChallengeOptions) UnmarshalJSON(bytes []byte) error {
v = &o.CloudflareOptions v = &o.CloudflareOptions
case C.DNSProviderACMEDNS: case C.DNSProviderACMEDNS:
v = &o.ACMEDNSOptions v = &o.ACMEDNSOptions
case C.DNSProviderTencentCloud:
v = &o.TencentCloudOptions
case C.DNSProviderDNSPod:
v = &o.DNSPodOptions
default: default:
return E.New("unknown provider type: " + o.Provider) return E.New("unknown provider type: " + o.Provider)
} }
@@ -94,3 +108,14 @@ type ACMEDNS01ACMEDNSOptions struct {
Subdomain string `json:"subdomain,omitempty"` Subdomain string `json:"subdomain,omitempty"`
ServerURL string `json:"server_url,omitempty"` ServerURL string `json:"server_url,omitempty"`
} }
type ACMEDNS01TencentCloudOptions struct {
SecretID string `json:"secret_id,omitempty"`
SecretKey string `json:"secret_key,omitempty"`
SessionToken string `json:"session_token,omitempty"`
Region string `json:"region,omitempty"`
}
type ACMEDNS01DNSPodOptions struct {
APIToken string `json:"api_token,omitempty"`
}

33
option/xboard.go Normal file
View 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"`
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net" "net"
"strings" "strings"
"sync"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/inbound"
@@ -35,6 +36,47 @@ type Inbound struct {
logger logger.ContextLogger logger logger.ContextLogger
listener *listener.Listener listener *listener.Listener
service *anytls.Service service *anytls.Service
options option.AnyTLSInboundOptions
tracker adapter.SSMTracker
ssmMutex sync.RWMutex
}
var _ adapter.ManagedSSMServer = (*Inbound)(nil)
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
h.tracker = tracker
}
func (h *Inbound) UpdateUsers(users []string, passwords []string, flows []string) error {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
paddingScheme := padding.DefaultPaddingScheme
if len(h.options.PaddingScheme) > 0 {
paddingScheme = []byte(strings.Join(h.options.PaddingScheme, "\n"))
}
anytlsUsers := make([]anytls.User, len(users))
for i := range users {
anytlsUsers[i] = anytls.User{
Name: users[i],
Password: passwords[i],
}
}
service, err := anytls.NewService(anytls.ServiceConfig{
Users: anytlsUsers,
PaddingScheme: paddingScheme,
Handler: (*inboundHandler)(h),
Logger: h.logger,
})
if err != nil {
return err
}
h.service = service
return nil
} }
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) { func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.AnyTLSInboundOptions) (adapter.Inbound, error) {
@@ -42,6 +84,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag), Adapter: inbound.NewAdapter(C.TypeAnyTLS, tag),
router: uot.NewRouter(router, logger), router: uot.NewRouter(router, logger),
logger: logger, logger: logger,
options: options,
} }
if options.TLS != nil && options.TLS.Enabled { if options.TLS != nil && options.TLS.Enabled {
@@ -106,7 +149,14 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
} }
conn = tlsConn conn = tlsConn
} }
err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) h.ssmMutex.RLock()
tracker := h.tracker
service := h.service
h.ssmMutex.RUnlock()
if tracker != nil {
conn = tracker.TrackConnection(conn, metadata)
}
err := service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
if err != nil { if err != nil {
N.CloseOnHandshakeFailure(conn, onClose, err) N.CloseOnHandshakeFailure(conn, onClose, err)
h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source)) h.logger.ErrorContext(ctx, E.Cause(err, "process connection from ", metadata.Source))

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net" "net"
"os" "os"
"sync"
"time" "time"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
@@ -42,6 +43,7 @@ type MultiInbound struct {
service shadowsocks.MultiService[int] service shadowsocks.MultiService[int]
users []option.ShadowsocksUser users []option.ShadowsocksUser
tracker adapter.SSMTracker tracker adapter.SSMTracker
ssmMutex sync.RWMutex
} }
func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) { func newMultiInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.ShadowsocksInboundOptions) (*MultiInbound, error) {
@@ -119,10 +121,14 @@ func (h *MultiInbound) Close() error {
} }
func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) { func (h *MultiInbound) SetTracker(tracker adapter.SSMTracker) {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
h.tracker = tracker h.tracker = tracker
} }
func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string) error { func (h *MultiInbound) UpdateUsers(users []string, uPSKs []string, flows []string) error {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int { err := h.service.UpdateUsersWithPasswords(common.MapIndexed(users, func(index int, user string) int {
return index return index
}), uPSKs) }), uPSKs)
@@ -163,7 +169,15 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
if !loaded { if !loaded {
return os.ErrInvalid return os.ErrInvalid
} }
user := h.users[userIndex].Name h.ssmMutex.RLock()
if userIndex < 0 || userIndex >= len(h.users) {
h.ssmMutex.RUnlock()
return os.ErrInvalid
}
userEntry := h.users[userIndex]
tracker := h.tracker
h.ssmMutex.RUnlock()
user := userEntry.Name
if user == "" { if user == "" {
user = F.ToString(userIndex) user = F.ToString(userIndex)
} else { } else {
@@ -174,9 +188,8 @@ func (h *MultiInbound) newConnection(ctx context.Context, conn net.Conn, metadat
metadata.InboundType = h.Type() metadata.InboundType = h.Type()
//nolint:staticcheck //nolint:staticcheck
metadata.InboundDetour = h.listener.ListenOptions().Detour metadata.InboundDetour = h.listener.ListenOptions().Detour
//nolint:staticcheck if tracker != nil {
if h.tracker != nil { conn = tracker.TrackConnection(conn, metadata)
conn = h.tracker.TrackConnection(conn, metadata)
} }
return h.router.RouteConnection(ctx, conn, metadata) return h.router.RouteConnection(ctx, conn, metadata)
} }
@@ -186,7 +199,15 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon
if !loaded { if !loaded {
return os.ErrInvalid return os.ErrInvalid
} }
user := h.users[userIndex].Name h.ssmMutex.RLock()
if userIndex < 0 || userIndex >= len(h.users) {
h.ssmMutex.RUnlock()
return os.ErrInvalid
}
userEntry := h.users[userIndex]
tracker := h.tracker
h.ssmMutex.RUnlock()
user := userEntry.Name
if user == "" { if user == "" {
user = F.ToString(userIndex) user = F.ToString(userIndex)
} else { } else {
@@ -199,9 +220,8 @@ func (h *MultiInbound) newPacketConnection(ctx context.Context, conn N.PacketCon
metadata.InboundType = h.Type() metadata.InboundType = h.Type()
//nolint:staticcheck //nolint:staticcheck
metadata.InboundDetour = h.listener.ListenOptions().Detour metadata.InboundDetour = h.listener.ListenOptions().Detour
//nolint:staticcheck if tracker != nil {
if h.tracker != nil { conn = tracker.TrackPacketConnection(conn, metadata)
conn = h.tracker.TrackPacketConnection(conn, metadata)
} }
return h.router.RoutePacketConnection(ctx, conn, metadata) return h.router.RoutePacketConnection(ctx, conn, metadata)
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net" "net"
"os" "os"
"sync"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/inbound"
@@ -31,7 +32,10 @@ func RegisterInbound(registry *inbound.Registry) {
inbound.Register[option.VLESSInboundOptions](registry, C.TypeVLESS, NewInbound) inbound.Register[option.VLESSInboundOptions](registry, C.TypeVLESS, NewInbound)
} }
var _ adapter.TCPInjectableInbound = (*Inbound)(nil) var (
_ adapter.TCPInjectableInbound = (*Inbound)(nil)
_ adapter.ManagedSSMServer = (*Inbound)(nil)
)
type Inbound struct { type Inbound struct {
inbound.Adapter inbound.Adapter
@@ -43,6 +47,40 @@ type Inbound struct {
service *vless.Service[int] service *vless.Service[int]
tlsConfig tls.ServerConfig tlsConfig tls.ServerConfig
transport adapter.V2RayServerTransport transport adapter.V2RayServerTransport
tracker adapter.SSMTracker
ssmMutex sync.RWMutex
}
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
h.tracker = tracker
}
func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
newUsers := make([]option.VLESSUser, len(users))
for i := range users {
flow := ""
if i < len(flows) {
flow = flows[i]
}
newUsers[i] = option.VLESSUser{
Name: users[i],
UUID: uuids[i],
Flow: flow,
}
}
h.users = newUsers
h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, _ option.VLESSUser) int {
return index
}), common.Map(h.users, func(it option.VLESSUser) string {
return it.UUID
}), common.Map(h.users, func(it option.VLESSUser) string {
return it.Flow
}))
return nil
} }
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) { func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSInboundOptions) (adapter.Inbound, error) {
@@ -172,13 +210,25 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return return
} }
user := h.users[userIndex].Name h.ssmMutex.RLock()
if userIndex < 0 || userIndex >= len(h.users) {
h.ssmMutex.RUnlock()
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
userEntry := h.users[userIndex]
tracker := h.tracker
h.ssmMutex.RUnlock()
user := userEntry.Name
if user == "" { if user == "" {
user = F.ToString(userIndex) user = F.ToString(userIndex)
} else { } else {
metadata.User = user metadata.User = user
} }
h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination) h.logger.InfoContext(ctx, "[", user, "] inbound connection to ", metadata.Destination)
if tracker != nil {
conn = tracker.TrackConnection(conn, metadata)
}
h.router.RouteConnectionEx(ctx, conn, metadata, onClose) h.router.RouteConnectionEx(ctx, conn, metadata, onClose)
} }
@@ -190,7 +240,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return return
} }
user := h.users[userIndex].Name h.ssmMutex.RLock()
if userIndex < 0 || userIndex >= len(h.users) {
h.ssmMutex.RUnlock()
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
userEntry := h.users[userIndex]
tracker := h.tracker
h.ssmMutex.RUnlock()
user := userEntry.Name
if user == "" { if user == "" {
user = F.ToString(userIndex) user = F.ToString(userIndex)
} else { } else {
@@ -203,6 +262,9 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
} else { } else {
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination)
} }
if tracker != nil {
conn = tracker.TrackPacketConnection(conn, metadata)
}
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net" "net"
"os" "os"
"sync"
"github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound" "github.com/sagernet/sing-box/adapter/inbound"
@@ -32,7 +33,10 @@ func RegisterInbound(registry *inbound.Registry) {
inbound.Register[option.VMessInboundOptions](registry, C.TypeVMess, NewInbound) inbound.Register[option.VMessInboundOptions](registry, C.TypeVMess, NewInbound)
} }
var _ adapter.TCPInjectableInbound = (*Inbound)(nil) var (
_ adapter.TCPInjectableInbound = (*Inbound)(nil)
_ adapter.ManagedSSMServer = (*Inbound)(nil)
)
type Inbound struct { type Inbound struct {
inbound.Adapter inbound.Adapter
@@ -44,6 +48,34 @@ type Inbound struct {
users []option.VMessUser users []option.VMessUser
tlsConfig tls.ServerConfig tlsConfig tls.ServerConfig
transport adapter.V2RayServerTransport transport adapter.V2RayServerTransport
tracker adapter.SSMTracker
ssmMutex sync.RWMutex
}
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
h.tracker = tracker
}
func (h *Inbound) UpdateUsers(users []string, uuids []string, flows []string) error {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
newUsers := make([]option.VMessUser, len(users))
for i := range users {
newUsers[i] = option.VMessUser{
Name: users[i],
UUID: uuids[i],
}
}
h.users = newUsers
return h.service.UpdateUsers(common.MapIndexed(h.users, func(index int, it option.VMessUser) int {
return index
}), common.Map(h.users, func(it option.VMessUser) string {
return it.UUID
}), common.Map(h.users, func(it option.VMessUser) int {
return it.AlterId
}))
} }
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) { func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessInboundOptions) (adapter.Inbound, error) {
@@ -163,6 +195,12 @@ func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata a
} }
conn = tlsConn conn = tlsConn
} }
h.ssmMutex.RLock()
tracker := h.tracker
h.ssmMutex.RUnlock()
if tracker != nil {
conn = tracker.TrackConnection(conn, metadata)
}
err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose) err := h.service.NewConnection(adapter.WithContext(ctx, &metadata), conn, metadata.Source, onClose)
if err != nil { if err != nil {
N.CloseOnHandshakeFailure(conn, onClose, err) N.CloseOnHandshakeFailure(conn, onClose, err)
@@ -178,7 +216,15 @@ func (h *Inbound) newConnectionEx(ctx context.Context, conn net.Conn, metadata a
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return return
} }
user := h.users[userIndex].Name h.ssmMutex.RLock()
if userIndex < 0 || userIndex >= len(h.users) {
h.ssmMutex.RUnlock()
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
userEntry := h.users[userIndex]
h.ssmMutex.RUnlock()
user := userEntry.Name
if user == "" { if user == "" {
user = F.ToString(userIndex) user = F.ToString(userIndex)
} else { } else {
@@ -196,7 +242,16 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid) N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return return
} }
user := h.users[userIndex].Name h.ssmMutex.RLock()
if userIndex < 0 || userIndex >= len(h.users) {
h.ssmMutex.RUnlock()
N.CloseOnHandshakeFailure(conn, onClose, os.ErrInvalid)
return
}
userEntry := h.users[userIndex]
tracker := h.tracker
h.ssmMutex.RUnlock()
user := userEntry.Name
if user == "" { if user == "" {
user = F.ToString(userIndex) user = F.ToString(userIndex)
} else { } else {
@@ -209,6 +264,9 @@ func (h *Inbound) newPacketConnectionEx(ctx context.Context, conn N.PacketConn,
} else { } else {
h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination) h.logger.InfoContext(ctx, "[", user, "] inbound packet connection to ", metadata.Destination)
} }
if tracker != nil {
conn = tracker.TrackPacketConnection(conn, metadata)
}
h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose) h.router.RoutePacketConnectionEx(ctx, conn, metadata, onClose)
} }

View File

@@ -29,7 +29,7 @@ func (m *UserManager) postUpdate(updated bool) error {
users = append(users, username) users = append(users, username)
uPSKs = append(uPSKs, password) uPSKs = append(uPSKs, password)
} }
err := m.server.UpdateUsers(users, uPSKs) err := m.server.UpdateUsers(users, uPSKs, nil)
if err != nil { if err != nil {
return err return err
} }

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
}