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">
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/raw/refs/heads/main/Github/Sponsor/Warp-Github-LG-02.png">
</a>
# sing-box Xboard Fork
---
这是一个基于上游 [SagerNet/sing-box](https://github.com/SagerNet/sing-box) 的定制分支,主要面向 Xboard / UniProxy 面板联动部署场景。
# sing-box
它保留了上游 `sing-box` 内核能力,同时补充了:
The universal proxy platform.
- Xboard 动态入站服务
- 多节点托管
- 面板协议自动识别
- VLESS / REALITY 面板字段映射
- Shadowsocks 2022 用户同步与密钥处理
- AnyTLS / Trojan / Hysteria / TUIC 的本地 TLS / ACME 支持
- 安装脚本与分离式配置模板
- PROXY protocol 客户端真实 IP 传递
[![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
```
```text
Copyright (C) 2022 by nekohasekai <contact-sagernet@sekai.icu>
This program is free software: you can redistribute it and/or modify
@@ -28,7 +440,7 @@ the Free Software Foundation, either version 3 of the License, or
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License

View File

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

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

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

View File

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

1
go.mod
View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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
}