diff --git a/README.md b/README.md
index 90be2a83..d3a7853f 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,436 @@
-> Sponsored by [Warp](https://go.warp.dev/sing-box), built for coding with multiple AI agents
+> Sponsored by CodeX
-
-
-
+# sing-box Xboard Fork
----
+这是一个基于上游 [SagerNet/sing-box](https://github.com/SagerNet/sing-box) 的定制分支,主要面向 Xboard / UniProxy 面板联动部署场景。
-# sing-box
+它保留了上游 `sing-box` 内核能力,同时补充了:
-The universal proxy platform.
+- Xboard 动态入站服务
+- 多节点托管
+- 面板协议自动识别
+- VLESS / REALITY 面板字段映射
+- Shadowsocks 2022 用户同步与密钥处理
+- AnyTLS / Trojan / Hysteria / TUIC 的本地 TLS / ACME 支持
+- 安装脚本与分离式配置模板
+- PROXY protocol 客户端真实 IP 传递
-[](https://repology.org/project/sing-box/versions)
+如果你要查看官方功能基线、通用配置文档、原始内核行为,请优先参考上游:
-## Documentation
+- 上游仓库:https://github.com/SagerNet/sing-box
+- 上游文档:https://sing-box.sagernet.org
-https://sing-box.sagernet.org
+## 适用场景
+
+这个仓库更适合以下用途:
+
+- 从 Xboard / UniProxy 面板拉取节点配置
+- 从面板拉取用户并动态更新
+- 单机运行多个节点
+- 给面板型机场节点准备安装脚本
+- 对接面板下发的 `protocol`、`cert_config`、`accept_proxy_protocol`、REALITY 字段等
+
+## 主要特性
+
+- `services.xboard` 动态服务
+- 支持单节点和多节点
+- 协议优先从面板返回的 `protocol` 识别
+- 支持面板回包控制 `accept_proxy_protocol`
+- 支持 VLESS REALITY 的 `server_name`、`public_key`、`private_key`、`short_id`
+- 支持 ACME:
+ - `auto_tls`
+ - `cert_mode = http`
+ - `cert_mode = dns`
+- 当前已支持的 ACME DNS provider:
+ - `cloudflare`
+ - `alidns`
+ - `tencentcloud`
+ - `dnspod`
+ - `acmedns`
+- 安装脚本默认生成:
+ - `/etc/sing-box/config.d/10-base.json`
+ - `/etc/sing-box/config.d/20-outbounds.json`
+- 安装后的服务名为:
+ - `singbox.service`
+
+## 仓库内关键文件
+
+- [install.sh](./install.sh)
+ Linux 安装脚本
+- [option/xboard.go](./option/xboard.go)
+ `services.xboard` 配置结构
+- [service/xboard/service.go](./service/xboard/service.go)
+ Xboard 动态服务实现
+- [configs](./configs)
+ 配置示例目录
+
+## 快速开始
+
+### 1. 编译并安装
+
+在 Linux 服务器上进入仓库目录:
+
+```bash
+curl -fsSL https://s3.cloudyun.top/downloads/singbox/install.sh | bash
+```
+`install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。
+
+
+脚本会做这些事情:
+
+1. 检查 Go 环境
+2. 编译当前仓库代码
+3. 生成分离配置
+4. 创建 `singbox.service`
+5. 启动服务
+
+### 2. 安装过程会询问的内容
+
+- `Panel URL`
+- `Panel Token`
+- 一个或多个 `Node ID`
+- DNS 模式:
+ - `udp`
+ - `local`
+- 当前节点前面是否有发送 PROXY protocol 的四层代理
+
+### 3. 多节点输入规则
+
+- 安装脚本会持续要求输入 `Node ID`
+- 输入 `NO` 才结束
+- 至少要有一个节点
+
+## 运行与管理
+
+查看状态:
+
+```bash
+systemctl status singbox
+```
+
+查看日志:
+
+```bash
+journalctl -u singbox -f
+```
+
+重启服务:
+
+```bash
+systemctl restart singbox
+```
+
+手动运行:
+
+```bash
+sing-box -D /var/lib/sing-box -C /etc/sing-box/config.d run
+```
+
+## 推荐配置结构
+
+建议把配置拆成两部分。
+
+### `10-base.json`
+
+放这些内容:
+
+- 日志
+- DNS
+- `services`
+- 基础路由规则
+
+### `20-outbounds.json`
+
+放这些内容:
+
+- 全部出站
+- 出站标签
+- 你自己的分流依赖
+
+这样更方便:
+
+- 面板动态服务和你的自定义出站分离
+- 安装脚本生成的基础配置不容易被误改
+- 调整出站时不会影响 Xboard 服务主体
+
+示例已放在:
+
+- [configs/10-base.single-node.json](./configs/10-base.single-node.json)
+- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json)
+- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json)
+
+## `services.xboard` 配置说明
+
+配置结构定义见 [option/xboard.go](./option/xboard.go)。
+
+### 最小单节点示例
+
+```json
+{
+ "type": "xboard",
+ "panel_url": "https://panel.example.com",
+ "key": "replace-with-node-token",
+ "sync_interval": "1m",
+ "report_interval": "1m",
+ "node_id": 286
+}
+```
+
+### 多节点示例
+
+```json
+{
+ "type": "xboard",
+ "panel_url": "https://panel.example.com",
+ "key": "replace-with-node-token",
+ "sync_interval": "1m",
+ "report_interval": "1m",
+ "nodes": [
+ {
+ "node_id": 286
+ },
+ {
+ "node_id": 774
+ }
+ ]
+}
+```
+
+### 当前推荐做法
+
+- 只传 `panel_url`
+- 只传 `key`
+- 只传 `node_id` 或 `nodes`
+
+下面这些字段仍然保留兼容,但常规部署一般不需要:
+
+- `config_panel_url`
+- `user_panel_url`
+- `config_node_id`
+- `user_node_id`
+- `node_type`
+
+说明:
+
+- 当前逻辑会优先从面板回包中的 `protocol` 自动识别协议
+- `node_type` 更适合做历史兼容,不建议再依赖它做主配置
+
+## 面板配置回包约定
+
+### 1. 协议识别
+
+推荐面板显式返回:
+
+```json
+{
+ "protocol": "vless"
+}
+```
+
+当前服务会按如下顺序识别协议:
+
+1. `protocol`
+2. `node_type`
+3. 如果是 Shadowsocks 且存在 `cipher`,则回退识别为 `shadowsocks`
+
+### 2. 监听地址与端口
+
+推荐面板返回:
+
+```json
+{
+ "listen_ip": "0.0.0.0",
+ "server_port": 443
+}
+```
+
+### 3. PROXY protocol
+
+如果前面有四层代理,并且它会发送 PROXY protocol 头,需要面板返回:
+
+```json
+{
+ "accept_proxy_protocol": true
+}
+```
+
+如果用户是直连节点,不要开启这个字段,否则连接会失败。
+
+### 4. VLESS REALITY
+
+当前支持从面板获取这些字段:
+
+- `tls_settings.server_name`
+- `tls_settings.public_key`
+- `tls_settings.private_key`
+- `tls_settings.short_id`
+- 顶层 `server_name`
+- 顶层 `public_key`
+- 顶层 `private_key`
+- 顶层 `short_id`
+
+示例见:
+
+- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json)
+
+### 5. Shadowsocks 2022
+
+推荐面板返回:
+
+- `protocol: "shadowsocks"`
+- `cipher`
+- `server_key`
+
+示例见:
+
+- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json)
+
+### 6. AnyTLS / Trojan / Hysteria / TUIC 的证书要求
+
+这些协议通常要求本地具备可用 TLS 证书。当前支持:
+
+- 文件证书
+- 证书内容直传
+- ACME 自动签发
+
+如果面板没有下发可用证书,也没有有效 ACME 配置,常见报错是:
+
+```text
+Xboard setup error: missing certificate
+```
+
+示例见:
+
+- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json)
+
+## ACME 说明
+
+当前支持:
+
+- `auto_tls: true`
+- `cert_mode: "http"`
+- `cert_mode: "dns"`
+
+### DNS-01 provider
+
+已支持:
+
+- `cloudflare`
+- `alidns`
+- `tencentcloud`
+- `dnspod`
+- `acmedns`
+
+### DNSPod 说明
+
+仓库已经内置 DNSPod provider 适配,不再依赖旧版 `github.com/libdns/dnspod` 的接口兼容。
+
+你可以从面板下发:
+
+```json
+{
+ "cert_config": {
+ "cert_mode": "dns",
+ "dns_provider": "dnspod",
+ "dns_env": {
+ "DNSPOD_TOKEN": "id,token"
+ }
+ }
+}
+```
+
+也可以下发腾讯云凭据:
+
+```json
+{
+ "cert_config": {
+ "cert_mode": "dns",
+ "dns_provider": "tencentcloud",
+ "dns_env": {
+ "TENCENTCLOUD_SECRET_ID": "xxx",
+ "TENCENTCLOUD_SECRET_KEY": "xxx"
+ }
+ }
+}
+```
+
+## 配置示例目录
+
+[configs](./configs) 目录中已经提供了这些示例:
+
+- [configs/10-base.single-node.json](./configs/10-base.single-node.json)
+ 单节点基础配置
+- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json)
+ 多节点基础配置
+- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json)
+ 出站配置模板
+- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json)
+ VLESS REALITY 面板回包
+- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json)
+ Shadowsocks 2022 面板回包
+- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json)
+ AnyTLS + ACME DNS 验证面板回包
+
+## 常见问题
+
+### `unsupported protocol: empty`
+
+原因通常是:
+
+- 面板没有返回 `protocol`
+- 兼容字段 `node_type` 也为空
+
+建议:
+
+- 面板显式返回顶层 `protocol`
+
+### `Xboard setup error: missing certificate`
+
+原因通常是:
+
+- AnyTLS / Trojan / Hysteria / TUIC 需要证书
+- 面板没有下发证书文件、证书内容或 ACME 参数
+
+### `TLS handshake: REALITY: processed invalid connection`
+
+多数情况下表示客户端参数和当前 REALITY 节点配置不匹配,例如:
+
+- `server_name` 错误
+- `public_key` 错误
+- `short_id` 错误
+- 客户端实际上不是按 REALITY 模式连入
+
+### `Server does not exist`
+
+通常是:
+
+- 面板里不存在该 `node_id`
+- `token` 不匹配
+- 拉取配置或拉取用户的节点 ID 写错
+
+### 真实 IP 没有正确获取
+
+请确认:
+
+1. 前置代理确实发送了 PROXY protocol
+2. 面板确实下发了 `accept_proxy_protocol: true`
+
+否则服务只能看到上游代理 IP。
+
+## 开发验证
+
+近期已验证通过的命令:
+
+```bash
+go test ./common/dnspod ./common/tls ./service/acme ./service/xboard
+go build -trimpath -tags 'with_quic,with_utls,with_clash_api,with_gvisor,with_acme' ./cmd/sing-box
+```
+
+如果你改动了 Xboard、协议映射、ACME 或证书相关逻辑,建议至少执行一次以上命令。
## License
-```
+```text
Copyright (C) 2022 by nekohasekai
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
@@ -36,4 +448,4 @@ along with this program. If not, see .
In addition, no derivative work may use the name or imply association
with this application without prior consent.
-```
\ No newline at end of file
+```
diff --git a/adapter/ssm.go b/adapter/ssm.go
index caab9221..3862e94f 100644
--- a/adapter/ssm.go
+++ b/adapter/ssm.go
@@ -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 {
diff --git a/building-install.sh b/building-install.sh
new file mode 100644
index 00000000..17a69da7
--- /dev/null
+++ b/building-install.sh
@@ -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 < "$CONFIG_BASE_FILE" < "$CONFIG_OUTBOUNDS_FILE" < "$SERVICE_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"
diff --git a/building-windows.ps1 b/building-windows.ps1
new file mode 100644
index 00000000..2988ff1f
--- /dev/null
+++ b/building-windows.ps1
@@ -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 Go binary path
+ -DistDir Output directory, default: .\dist
+ -GoCacheDir Go build cache directory, default: .\.cache\go-build
+ -GoModCacheDir Go module cache directory, default: .\.cache\gomod
+ -CgoEnabledValue <0|1> CGO_ENABLED value, default: 0
+ -BuildJobs Go build parallel jobs, default: GO_BUILD_JOBS or CPU core count
+ -Version Embedded version, default: git describe --tags --always
+ -BuildTagsOthers Override non-Windows build tags
+ -BuildTagsWindows 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
+}
diff --git a/common/dnspod/provider.go b/common/dnspod/provider.go
new file mode 100644
index 00000000..d559cbff
--- /dev/null
+++ b/common/dnspod/provider.go
@@ -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
+}
diff --git a/common/tls/acme.go b/common/tls/acme.go
index c96e002c..96a45ba5 100644
--- a/common/tls/acme.go
+++ b/common/tls/acme.go
@@ -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,
diff --git a/configs/10-base.multi-node.json b/configs/10-base.multi-node.json
new file mode 100644
index 00000000..185fe0d3
--- /dev/null
+++ b/configs/10-base.multi-node.json
@@ -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
+ }
+}
diff --git a/configs/10-base.single-node.json b/configs/10-base.single-node.json
new file mode 100644
index 00000000..749920cf
--- /dev/null
+++ b/configs/10-base.single-node.json
@@ -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
+ }
+}
diff --git a/configs/20-outbounds.example.json b/configs/20-outbounds.example.json
new file mode 100644
index 00000000..5573444d
--- /dev/null
+++ b/configs/20-outbounds.example.json
@@ -0,0 +1,12 @@
+{
+ "outbounds": [
+ {
+ "type": "direct",
+ "tag": "direct"
+ },
+ {
+ "type": "block",
+ "tag": "block"
+ }
+ ]
+}
diff --git a/configs/panel-response.anytls-acme-dns.json b/configs/panel-response.anytls-acme-dns.json
new file mode 100644
index 00000000..347f717d
--- /dev/null
+++ b/configs/panel-response.anytls-acme-dns.json
@@ -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
+ }
+}
diff --git a/configs/panel-response.shadowsocks2022.json b/configs/panel-response.shadowsocks2022.json
new file mode 100644
index 00000000..509e8e2d
--- /dev/null
+++ b/configs/panel-response.shadowsocks2022.json
@@ -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
+ }
+}
diff --git a/configs/panel-response.vless-reality.json b/configs/panel-response.vless-reality.json
new file mode 100644
index 00000000..4eb129f6
--- /dev/null
+++ b/configs/panel-response.vless-reality.json
@@ -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
+ }
+}
diff --git a/constant/dns.go b/constant/dns.go
index 15d6096c..6f5763f7 100644
--- a/constant/dns.go
+++ b/constant/dns.go
@@ -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))
+ }
+}
diff --git a/constant/proxy.go b/constant/proxy.go
index 278a46c2..c7247efd 100644
--- a/constant/proxy.go
+++ b/constant/proxy.go
@@ -31,6 +31,7 @@ const (
TypeCCM = "ccm"
TypeOCM = "ocm"
TypeOOMKiller = "oom-killer"
+ TypeXBoard = "xboard"
)
const (
diff --git a/go.mod b/go.mod
index 5aec4dba..31b63b95 100644
--- a/go.mod
+++ b/go.mod
@@ -18,6 +18,7 @@ require (
github.com/libdns/acmedns v0.5.0
github.com/libdns/alidns v1.0.6
github.com/libdns/cloudflare v0.2.2
+ github.com/libdns/tencentcloud v1.4.3
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/metacubex/utls v1.8.4
github.com/mholt/acmez/v3 v3.1.6
diff --git a/include/registry.go b/include/registry.go
index f090845b..ec8550f1 100644
--- a/include/registry.go
+++ b/include/registry.go
@@ -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)
diff --git a/install.sh b/install.sh
new file mode 100644
index 00000000..984a195d
--- /dev/null
+++ b/install.sh
@@ -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/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:-}, NodeIDs=$(IFS=,; echo "${V2BX_IMPORTED_NODE_IDS[*]}")${NC}"
+ else
+ echo -e "${YELLOW}Imported defaults from V2bX config: ApiHost=${PANEL_URL:-}, NodeIDs=${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 < "$CONFIG_BASE_FILE" < "$CONFIG_OUTBOUNDS_FILE" < "$SERVICE_FILE" < 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))
diff --git a/protocol/shadowsocks/inbound_multi.go b/protocol/shadowsocks/inbound_multi.go
index 7ff92646..7a12849c 100644
--- a/protocol/shadowsocks/inbound_multi.go
+++ b/protocol/shadowsocks/inbound_multi.go
@@ -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)
}
diff --git a/protocol/vless/inbound.go b/protocol/vless/inbound.go
index 75cd4124..f9a692e1 100644
--- a/protocol/vless/inbound.go
+++ b/protocol/vless/inbound.go
@@ -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)
}
diff --git a/protocol/vmess/inbound.go b/protocol/vmess/inbound.go
index 4e9c763c..a01b764f 100644
--- a/protocol/vmess/inbound.go
+++ b/protocol/vmess/inbound.go
@@ -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)
}
diff --git a/service/ssmapi/user.go b/service/ssmapi/user.go
index 26bc621a..21aba15c 100644
--- a/service/ssmapi/user.go
+++ b/service/ssmapi/user.go
@@ -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
}
diff --git a/service/xboard/multi_service.go b/service/xboard/multi_service.go
new file mode 100644
index 00000000..eb1ff1a6
--- /dev/null
+++ b/service/xboard/multi_service.go
@@ -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
+}
diff --git a/service/xboard/service.go b/service/xboard/service.go
new file mode 100644
index 00000000..45272dd5
--- /dev/null
+++ b/service/xboard/service.go
@@ -0,0 +1,1963 @@
+package xboard
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math/rand"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "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"
+ "github.com/sagernet/sing-box/service/ssmapi"
+ E "github.com/sagernet/sing/common/exceptions"
+ "github.com/sagernet/sing/common/json/badoption"
+ "github.com/sagernet/sing/service"
+)
+
+// ss2022Key prepares a key for SS2022 by truncating/padding the seed and encoding as Base64.
+// This matches the logic in V2bX/core/sing/user.go.
+func ss2022Key(seed string, keyLen int) string {
+ if len(seed) > keyLen {
+ seed = seed[:keyLen]
+ } else if len(seed) < keyLen {
+ padded := make([]byte, keyLen)
+ copy(padded, []byte(seed))
+ seed = string(padded)
+ }
+ return base64.StdEncoding.EncodeToString([]byte(seed))
+}
+
+// ss2022KeyLength returns the required key length for a given SS2022 cipher.
+func ss2022KeyLength(cipher string) int {
+ switch cipher {
+ case "2022-blake3-aes-128-gcm":
+ return 16
+ case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
+ return 32
+ default:
+ return 32
+ }
+}
+
+func RegisterService(registry *boxService.Registry) {
+ boxService.Register[option.XBoardServiceOptions](registry, C.TypeXBoard, NewService)
+}
+
+type Service struct {
+ boxService.Adapter
+ ctx context.Context
+ cancel context.CancelFunc
+ logger log.ContextLogger
+ options option.XBoardServiceOptions
+ httpClient *http.Client
+ traffics map[string]*ssmapi.TrafficManager
+ users map[string]*ssmapi.UserManager
+ servers map[string]adapter.ManagedSSMServer
+ localUsers map[string]userData
+ aliveUsers map[string]map[string]time.Time
+ inboundTags []string
+ syncTicker *time.Ticker
+ reportTicker *time.Ticker
+ aliveTicker *time.Ticker
+ access sync.Mutex
+ router adapter.Router
+ inboundManager adapter.InboundManager
+ protocol string
+ vlessFlow string
+ vlessServerName string
+ ssCipher string // stored for user key derivation in syncUsers
+ ssServerKey string // stored for SS2022 per-user key extraction
+}
+
+type XNodeConfig struct {
+ NodeType string `json:"node_type"`
+ NodeType_ string `json:"nodeType"`
+ ServerConfig json.RawMessage `json:"server_config"`
+ ServerConfig_ json.RawMessage `json:"serverConfig"`
+ Config json.RawMessage `json:"config"`
+ ListenIP string `json:"listen_ip"`
+ Port int `json:"port"`
+ ServerPort int `json:"server_port"`
+ Protocol string `json:"protocol"`
+ Cipher string `json:"cipher"`
+ ServerKey string `json:"server_key"`
+ TLS int `json:"tls"`
+ Flow string `json:"flow"`
+ TLSSettings *XTLSSettings `json:"tls_settings"`
+ TLSSettings_ *XTLSSettings `json:"tlsSettings"`
+ PublicKey string `json:"public_key,omitempty"`
+ PrivateKey string `json:"private_key,omitempty"`
+ ShortID string `json:"short_id,omitempty"`
+ ShortIDs []string `json:"short_ids,omitempty"`
+ Dest string `json:"dest,omitempty"`
+ ServerName string `json:"server_name,omitempty"`
+ ServerPortText string `json:"server_port_text,omitempty"`
+ Network string `json:"network"`
+ DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"`
+ DisableTCPKeepAlive_ bool `json:"disableTcpKeepAlive,omitempty"`
+ TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
+ TCPKeepAlive_ badoption.Duration `json:"tcpKeepAlive,omitempty"`
+ TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
+ TCPKeepAliveInterval_ badoption.Duration `json:"tcpKeepAliveInterval,omitempty"`
+ AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"`
+ AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"`
+ Multiplex *XMultiplexConfig `json:"multiplex,omitempty"`
+ NetworkSettings json.RawMessage `json:"network_settings"`
+ NetworkSettings_ json.RawMessage `json:"networkSettings"`
+
+ // Hysteria / Hysteria2
+ UpMbps int `json:"up_mbps"`
+ DownMbps int `json:"down_mbps"`
+ Obfs string `json:"obfs"`
+ ObfsPassword string `json:"obfs-password"`
+ Ignore_Client_Bandwidth bool `json:"ignore_client_bandwidth"`
+
+ // Tuic
+ CongestionControl string `json:"congestion_control"`
+ ZeroRTTHandshake bool `json:"zero_rtt_handshake"`
+
+ // AnyTls
+ PaddingScheme []string `json:"padding_scheme"`
+
+ // TLS certificate settings
+ CertConfig *XCertConfig `json:"cert_config,omitempty"`
+ AutoTLS bool `json:"auto_tls,omitempty"`
+ Domain string `json:"domain,omitempty"`
+}
+
+type XCertConfig struct {
+ CertMode string `json:"cert_mode"`
+ Domain string `json:"domain"`
+ Email string `json:"email"`
+ DNSProvider string `json:"dns_provider"`
+ DNSEnv map[string]string `json:"dns_env"`
+ HTTPPort int `json:"http_port"`
+ CertFile string `json:"cert_file"`
+ KeyFile string `json:"key_file"`
+ CertContent string `json:"cert_content"`
+ KeyContent string `json:"key_content"`
+}
+
+type XInnerConfig struct {
+ ListenIP string `json:"listen_ip"`
+ Port int `json:"port"`
+ ServerPort int `json:"server_port"`
+ Protocol string `json:"protocol"`
+ NodeType string `json:"node_type"`
+ Cipher string `json:"cipher"`
+ ServerKey string `json:"server_key"`
+ TLS int `json:"tls"`
+ Flow string `json:"flow"`
+ TLSSettings *XTLSSettings `json:"tls_settings"`
+ TLSSettings_ *XTLSSettings `json:"tlsSettings"`
+ PublicKey string `json:"public_key,omitempty"`
+ PrivateKey string `json:"private_key,omitempty"`
+ ShortID string `json:"short_id,omitempty"`
+ ShortIDs []string `json:"short_ids,omitempty"`
+ Dest string `json:"dest,omitempty"`
+ ServerName string `json:"server_name,omitempty"`
+ Network string `json:"network"`
+ DisableTCPKeepAlive bool `json:"disable_tcp_keep_alive,omitempty"`
+ DisableTCPKeepAlive_ bool `json:"disableTcpKeepAlive,omitempty"`
+ TCPKeepAlive badoption.Duration `json:"tcp_keep_alive,omitempty"`
+ TCPKeepAlive_ badoption.Duration `json:"tcpKeepAlive,omitempty"`
+ TCPKeepAliveInterval badoption.Duration `json:"tcp_keep_alive_interval,omitempty"`
+ TCPKeepAliveInterval_ badoption.Duration `json:"tcpKeepAliveInterval,omitempty"`
+ AcceptProxyProtocol bool `json:"accept_proxy_protocol,omitempty"`
+ AcceptProxyProtocol_ bool `json:"acceptProxyProtocol,omitempty"`
+ Multiplex *XMultiplexConfig `json:"multiplex,omitempty"`
+ NetworkSettings json.RawMessage `json:"network_settings"`
+ NetworkSettings_ json.RawMessage `json:"networkSettings"`
+ StreamSettings json.RawMessage `json:"streamSettings"`
+ UpMbps int `json:"up_mbps"`
+ DownMbps int `json:"down_mbps"`
+ CertConfig *XCertConfig `json:"cert_config,omitempty"`
+ AutoTLS bool `json:"auto_tls,omitempty"`
+ Domain string `json:"domain,omitempty"`
+}
+
+type XMultiplexConfig struct {
+ Enabled bool `json:"enabled"`
+ Protocol string `json:"protocol,omitempty"`
+ MaxConnections int `json:"max_connections,omitempty"`
+ MinStreams int `json:"min_streams,omitempty"`
+ MaxStreams int `json:"max_streams,omitempty"`
+ Padding bool `json:"padding,omitempty"`
+ Brutal *XBrutalConfig `json:"brutal,omitempty"`
+}
+
+type XBrutalConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ UpMbps int `json:"up_mbps,omitempty"`
+ DownMbps int `json:"down_mbps,omitempty"`
+}
+
+type HttpNetworkConfig struct {
+ Header struct {
+ Type string `json:"type"`
+ Request *json.RawMessage `json:"request"`
+ Response *json.RawMessage `json:"response"`
+ } `json:"header"`
+}
+
+type HttpRequest struct {
+ Version string `json:"version"`
+ Method string `json:"method"`
+ Path []string `json:"path"`
+ Headers struct {
+ Host []string `json:"Host"`
+ } `json:"headers"`
+}
+
+type WsNetworkConfig struct {
+ Path string `json:"path"`
+ Headers map[string]string `json:"headers"`
+}
+
+type GrpcNetworkConfig struct {
+ ServiceName string `json:"serviceName"`
+}
+
+func unmarshalNetworkSettings(settings json.RawMessage) map[string]any {
+ if len(settings) == 0 {
+ return nil
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(settings, &raw); err != nil {
+ return nil
+ }
+ return raw
+}
+
+func readNetworkString(raw map[string]any, keys ...string) string {
+ for _, key := range keys {
+ value, exists := raw[key]
+ if !exists {
+ continue
+ }
+ if stringValue, ok := value.(string); ok && stringValue != "" {
+ return stringValue
+ }
+ }
+ return ""
+}
+
+func readNetworkBool(raw map[string]any, keys ...string) (bool, bool) {
+ for _, key := range keys {
+ value, exists := raw[key]
+ if !exists {
+ continue
+ }
+ if boolValue, ok := value.(bool); ok {
+ return boolValue, true
+ }
+ }
+ return false, false
+}
+
+func readNetworkDuration(raw map[string]any, keys ...string) (badoption.Duration, bool) {
+ for _, key := range keys {
+ value, exists := raw[key]
+ if !exists {
+ continue
+ }
+ switch typedValue := value.(type) {
+ case float64:
+ return badoption.Duration(time.Duration(typedValue) * time.Second), true
+ case string:
+ if typedValue == "" {
+ continue
+ }
+ if durationValue, err := time.ParseDuration(typedValue); err == nil {
+ return badoption.Duration(durationValue), true
+ }
+ if secondsValue, err := strconv.ParseInt(typedValue, 10, 64); err == nil {
+ return badoption.Duration(time.Duration(secondsValue) * time.Second), true
+ }
+ }
+ }
+ return 0, false
+}
+
+type HttpupgradeNetworkConfig struct {
+ Path string `json:"path"`
+ Host string `json:"host"`
+}
+
+type XTLSSettings struct {
+ ServerName string `json:"server_name"`
+ ServerPort string `json:"server_port"`
+ PublicKey string `json:"public_key"`
+ PrivateKey string `json:"private_key"`
+ ShortID string `json:"short_id"`
+ ShortIDs []string `json:"short_ids"`
+ AllowInsecure bool `json:"allow_insecure"`
+ Dest string `json:"dest"`
+}
+
+type XRealitySettings struct {
+ Dest string `json:"dest"`
+ ServerNames []string `json:"serverNames"`
+ ServerNames_ []string `json:"server_names"`
+ PrivateKey string `json:"privateKey"`
+ PrivateKey_ string `json:"private_key"`
+ ShortId string `json:"shortId"`
+ ShortId_ string `json:"short_id"`
+ ShortIds []string `json:"shortIds"`
+ ShortIds_ []string `json:"short_ids"`
+}
+
+func (r *XRealitySettings) GetPrivateKey() string {
+ if r.PrivateKey != "" {
+ return r.PrivateKey
+ }
+ return r.PrivateKey_
+}
+
+func (r *XRealitySettings) GetShortIds() []string {
+ if len(r.ShortIds) > 0 {
+ return r.ShortIds
+ }
+ if len(r.ShortIds_) > 0 {
+ return r.ShortIds_
+ }
+ if r.ShortId != "" {
+ return []string{r.ShortId}
+ }
+ if r.ShortId_ != "" {
+ return []string{r.ShortId_}
+ }
+ return nil
+}
+
+func (r *XRealitySettings) GetServerNames() []string {
+ if len(r.ServerNames) > 0 {
+ return r.ServerNames
+ }
+ return r.ServerNames_
+}
+
+type XStreamSettings struct {
+ Network string `json:"network"`
+ Security string `json:"security"`
+ RealitySettings XRealitySettings `json:"realitySettings"`
+ RealitySettings_ XRealitySettings `json:"reality_settings"`
+}
+
+func (s *XStreamSettings) GetReality() *XRealitySettings {
+ if s.RealitySettings.GetPrivateKey() != "" {
+ return &s.RealitySettings
+ }
+ return &s.RealitySettings_
+}
+
+func NewService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) {
+ if len(options.Nodes) > 0 {
+ return newMultiNodeService(ctx, logger, tag, options)
+ }
+ return newSingleService(ctx, logger, tag, options)
+}
+
+func newSingleService(ctx context.Context, logger log.ContextLogger, tag string, options option.XBoardServiceOptions) (adapter.Service, error) {
+ ctx, cancel := context.WithCancel(ctx)
+ s := &Service{
+ Adapter: boxService.NewAdapter(C.TypeXBoard, tag),
+ ctx: ctx,
+ cancel: cancel,
+ logger: logger,
+ options: options,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ traffics: make(map[string]*ssmapi.TrafficManager),
+ users: make(map[string]*ssmapi.UserManager),
+ servers: make(map[string]adapter.ManagedSSMServer),
+ aliveUsers: make(map[string]map[string]time.Time),
+ syncTicker: time.NewTicker(time.Duration(options.SyncInterval)),
+ reportTicker: time.NewTicker(time.Duration(options.ReportInterval)),
+ aliveTicker: time.NewTicker(1 * time.Minute),
+ router: service.FromContext[adapter.Router](ctx),
+ inboundManager: service.FromContext[adapter.InboundManager](ctx),
+ }
+
+ if s.options.SyncInterval == 0 {
+ s.syncTicker.Stop()
+ s.syncTicker = time.NewTicker(1 * time.Minute)
+ }
+ if s.options.ReportInterval == 0 {
+ s.reportTicker.Stop()
+ s.reportTicker = time.NewTicker(1 * time.Minute)
+ }
+ s.aliveTicker.Stop()
+ s.aliveTicker = time.NewTicker(1 * time.Minute)
+
+ return s, nil
+}
+
+func (s *Service) inboundTag() string {
+ if s.Tag() != "" {
+ return "xboard-inbound-" + s.Tag()
+ }
+ if s.options.NodeID != 0 {
+ return "xboard-inbound-" + strconv.Itoa(s.options.NodeID)
+ }
+ return "xboard-inbound"
+}
+
+func applyCertConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig) bool {
+ if certConfig == nil {
+ return false
+ }
+ switch certConfig.CertMode {
+ case "", "none":
+ return false
+ case "file":
+ if certConfig.CertFile == "" || certConfig.KeyFile == "" {
+ return false
+ }
+ tlsOptions.CertificatePath = certConfig.CertFile
+ tlsOptions.KeyPath = certConfig.KeyFile
+ return true
+ case "content":
+ if certConfig.CertContent == "" || certConfig.KeyContent == "" {
+ return false
+ }
+ tlsOptions.Certificate = badoption.Listable[string]{certConfig.CertContent}
+ tlsOptions.Key = badoption.Listable[string]{certConfig.KeyContent}
+ return true
+ default:
+ return false
+ }
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func mergedCertConfig(inner XInnerConfig, config *XNodeConfig) *XCertConfig {
+ if inner.CertConfig != nil {
+ return inner.CertConfig
+ }
+ if config != nil {
+ return config.CertConfig
+ }
+ return nil
+}
+
+func hasUsableServerTLS(tlsOptions option.InboundTLSOptions) bool {
+ return tlsOptions.ACME != nil && len(tlsOptions.ACME.Domain) > 0 ||
+ (tlsOptions.CertificatePath != "" && tlsOptions.KeyPath != "") ||
+ (len(tlsOptions.Certificate) > 0 && len(tlsOptions.Key) > 0)
+}
+
+func applyACMEConfigDetailed(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) (bool, string) {
+ if !autoTLS && certConfig == nil {
+ return false, "acme disabled: no auto_tls and no cert_config"
+ }
+ mode := ""
+ if certConfig != nil {
+ mode = strings.ToLower(strings.TrimSpace(certConfig.CertMode))
+ }
+ if mode == "" && autoTLS {
+ mode = "http"
+ }
+ if mode != "http" && mode != "dns" {
+ return false, "unsupported cert_mode: " + mode
+ }
+ domain = strings.TrimSpace(domain)
+ if domain == "" {
+ return false, "missing domain/server_name for ACME"
+ }
+
+ acmeOptions := &option.InboundACMEOptions{
+ Domain: badoption.Listable[string]{domain},
+ DataDirectory: "acme",
+ DefaultServerName: domain,
+ }
+ if certConfig != nil {
+ acmeOptions.Email = strings.TrimSpace(certConfig.Email)
+ }
+ switch mode {
+ case "http":
+ acmeOptions.DisableHTTPChallenge = false
+ acmeOptions.DisableTLSALPNChallenge = true
+ if certConfig != nil && certConfig.HTTPPort > 0 && certConfig.HTTPPort != 80 {
+ acmeOptions.AlternativeHTTPPort = uint16(certConfig.HTTPPort)
+ }
+ case "dns":
+ acmeOptions.DisableHTTPChallenge = true
+ acmeOptions.DisableTLSALPNChallenge = true
+ if listenPort > 0 && listenPort != 443 {
+ acmeOptions.AlternativeTLSPort = uint16(listenPort)
+ }
+ }
+ if mode == "dns" && certConfig != nil {
+ dnsProvider := C.NormalizeACMEDNSProvider(certConfig.DNSProvider)
+ if dnsProvider == "" {
+ return false, "missing dns_provider for cert_mode=dns"
+ }
+ dns01 := &option.ACMEDNS01ChallengeOptions{Provider: dnsProvider}
+ switch dnsProvider {
+ case C.DNSProviderCloudflare:
+ dns01.CloudflareOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["CF_API_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_API_TOKEN"])
+ dns01.CloudflareOptions.ZoneToken = firstNonEmpty(certConfig.DNSEnv["CF_ZONE_TOKEN"], certConfig.DNSEnv["CLOUDFLARE_ZONE_TOKEN"])
+ if dns01.CloudflareOptions.APIToken == "" && dns01.CloudflareOptions.ZoneToken == "" {
+ return false, "cloudflare dns challenge requires CF_API_TOKEN/CLOUDFLARE_API_TOKEN or CF_ZONE_TOKEN/CLOUDFLARE_ZONE_TOKEN"
+ }
+ case C.DNSProviderAliDNS:
+ dns01.AliDNSOptions.AccessKeyID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_ID"], certConfig.DNSEnv["ALI_ACCESS_KEY_ID"])
+ dns01.AliDNSOptions.AccessKeySecret = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_ACCESS_KEY_SECRET"], certConfig.DNSEnv["ALI_ACCESS_KEY_SECRET"])
+ dns01.AliDNSOptions.RegionID = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_REGION_ID"], certConfig.DNSEnv["ALI_REGION_ID"])
+ dns01.AliDNSOptions.SecurityToken = firstNonEmpty(certConfig.DNSEnv["ALICLOUD_SECURITY_TOKEN"], certConfig.DNSEnv["ALI_SECURITY_TOKEN"])
+ if dns01.AliDNSOptions.AccessKeyID == "" || dns01.AliDNSOptions.AccessKeySecret == "" {
+ return false, "alidns dns challenge requires ALICLOUD_ACCESS_KEY_ID and ALICLOUD_ACCESS_KEY_SECRET"
+ }
+ case C.DNSProviderTencentCloud:
+ dns01.TencentCloudOptions.SecretID = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_ID"], certConfig.DNSEnv["TENCENT_SECRET_ID"], certConfig.DNSEnv["SECRET_ID"])
+ dns01.TencentCloudOptions.SecretKey = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_KEY"], certConfig.DNSEnv["TENCENT_SECRET_KEY"], certConfig.DNSEnv["SECRET_KEY"])
+ dns01.TencentCloudOptions.SessionToken = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SESSION_TOKEN"], certConfig.DNSEnv["TENCENT_SESSION_TOKEN"], certConfig.DNSEnv["SESSION_TOKEN"])
+ dns01.TencentCloudOptions.Region = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_REGION"], certConfig.DNSEnv["TENCENT_REGION"], certConfig.DNSEnv["REGION"])
+ if dns01.TencentCloudOptions.SecretID == "" || dns01.TencentCloudOptions.SecretKey == "" {
+ return false, "tencentcloud dns challenge requires TENCENTCLOUD_SECRET_ID and TENCENTCLOUD_SECRET_KEY"
+ }
+ case C.DNSProviderDNSPod:
+ dns01.DNSPodOptions.APIToken = firstNonEmpty(certConfig.DNSEnv["DNSPOD_TOKEN"], certConfig.DNSEnv["API_TOKEN"])
+ if dns01.DNSPodOptions.APIToken == "" {
+ tencentSecretID := firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_ID"], certConfig.DNSEnv["TENCENT_SECRET_ID"], certConfig.DNSEnv["SECRET_ID"])
+ tencentSecretKey := firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SECRET_KEY"], certConfig.DNSEnv["TENCENT_SECRET_KEY"], certConfig.DNSEnv["SECRET_KEY"])
+ if tencentSecretID == "" || tencentSecretKey == "" {
+ return false, "dnspod dns challenge requires DNSPOD_TOKEN or TencentCloud SecretID/SecretKey"
+ }
+ dns01.Provider = C.DNSProviderTencentCloud
+ dns01.TencentCloudOptions.SecretID = tencentSecretID
+ dns01.TencentCloudOptions.SecretKey = tencentSecretKey
+ dns01.TencentCloudOptions.SessionToken = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_SESSION_TOKEN"], certConfig.DNSEnv["TENCENT_SESSION_TOKEN"], certConfig.DNSEnv["SESSION_TOKEN"])
+ dns01.TencentCloudOptions.Region = firstNonEmpty(certConfig.DNSEnv["TENCENTCLOUD_REGION"], certConfig.DNSEnv["TENCENT_REGION"], certConfig.DNSEnv["REGION"])
+ }
+ case C.DNSProviderACMEDNS:
+ dns01.ACMEDNSOptions.Username = certConfig.DNSEnv["ACMEDNS_USERNAME"]
+ dns01.ACMEDNSOptions.Password = certConfig.DNSEnv["ACMEDNS_PASSWORD"]
+ dns01.ACMEDNSOptions.Subdomain = certConfig.DNSEnv["ACMEDNS_SUBDOMAIN"]
+ dns01.ACMEDNSOptions.ServerURL = certConfig.DNSEnv["ACMEDNS_SERVER_URL"]
+ if dns01.ACMEDNSOptions.Username == "" || dns01.ACMEDNSOptions.Password == "" || dns01.ACMEDNSOptions.Subdomain == "" || dns01.ACMEDNSOptions.ServerURL == "" {
+ return false, "acmedns dns challenge requires username, password, subdomain and server_url"
+ }
+ default:
+ return false, "unsupported dns_provider: " + dnsProvider
+ }
+ acmeOptions.DNS01Challenge = dns01
+ }
+ tlsOptions.ACME = acmeOptions
+ return true, ""
+}
+
+func applyACMEConfig(tlsOptions *option.InboundTLSOptions, certConfig *XCertConfig, autoTLS bool, domain string, listenPort int) bool {
+ ok, _ := applyACMEConfigDetailed(tlsOptions, certConfig, autoTLS, domain, listenPort)
+ return ok
+}
+
+func mergedTLSSettings(inner XInnerConfig, config *XNodeConfig) *XTLSSettings {
+ tlsSettings := inner.TLSSettings
+ if tlsSettings == nil {
+ tlsSettings = inner.TLSSettings_
+ }
+ if tlsSettings == nil && config != nil {
+ tlsSettings = config.TLSSettings
+ }
+ if tlsSettings == nil && config != nil {
+ tlsSettings = config.TLSSettings_
+ }
+ if tlsSettings == nil {
+ tlsSettings = &XTLSSettings{}
+ }
+
+ if tlsSettings.PublicKey == "" {
+ if inner.PublicKey != "" {
+ tlsSettings.PublicKey = inner.PublicKey
+ } else if config != nil {
+ tlsSettings.PublicKey = config.PublicKey
+ }
+ }
+ if tlsSettings.PrivateKey == "" {
+ if inner.PrivateKey != "" {
+ tlsSettings.PrivateKey = inner.PrivateKey
+ } else if config != nil {
+ tlsSettings.PrivateKey = config.PrivateKey
+ }
+ }
+ if tlsSettings.ShortID == "" {
+ if inner.ShortID != "" {
+ tlsSettings.ShortID = inner.ShortID
+ } else if config != nil {
+ tlsSettings.ShortID = config.ShortID
+ }
+ }
+ if len(tlsSettings.ShortIDs) == 0 {
+ if len(inner.ShortIDs) > 0 {
+ tlsSettings.ShortIDs = inner.ShortIDs
+ } else if config != nil && len(config.ShortIDs) > 0 {
+ tlsSettings.ShortIDs = config.ShortIDs
+ }
+ }
+ if tlsSettings.Dest == "" {
+ if inner.Dest != "" {
+ tlsSettings.Dest = inner.Dest
+ } else if config != nil {
+ tlsSettings.Dest = config.Dest
+ }
+ }
+ if tlsSettings.ServerName == "" {
+ if inner.ServerName != "" {
+ tlsSettings.ServerName = inner.ServerName
+ } else if config != nil {
+ tlsSettings.ServerName = config.ServerName
+ }
+ }
+ if tlsSettings.ServerPort == "" && config != nil && config.ServerPortText != "" {
+ tlsSettings.ServerPort = config.ServerPortText
+ }
+ return tlsSettings
+}
+
+func buildInboundMultiplex(config *XMultiplexConfig) *option.InboundMultiplexOptions {
+ if config == nil || !config.Enabled {
+ return nil
+ }
+ var brutal *option.BrutalOptions
+ if config.Brutal != nil {
+ brutal = &option.BrutalOptions{
+ Enabled: config.Brutal.Enabled,
+ UpMbps: config.Brutal.UpMbps,
+ DownMbps: config.Brutal.DownMbps,
+ }
+ }
+ return &option.InboundMultiplexOptions{
+ Enabled: config.Enabled,
+ Padding: config.Padding,
+ Brutal: brutal,
+ }
+}
+
+func (s *Service) Start(stage adapter.StartStage) error {
+ if stage != adapter.StartStateStart {
+ return nil
+ }
+
+ // Fetch node config and setup inbound
+ err := s.setupNode()
+ if err != nil {
+ s.logger.Error("Xboard setup error: ", err)
+ // Don't return error to allow sing-box to continue, service will retry in loop
+ }
+
+ go s.loop()
+ return nil
+}
+
+func getInboundTransport(network string, settings json.RawMessage) (*option.V2RayTransportOptions, error) {
+ if network == "" {
+ return nil, nil
+ }
+ t := &option.V2RayTransportOptions{
+ Type: network,
+ }
+ rawSettings := unmarshalNetworkSettings(settings)
+ switch network {
+ case "tcp":
+ if len(settings) != 0 {
+ var networkConfig HttpNetworkConfig
+ err := json.Unmarshal(settings, &networkConfig)
+ if err != nil {
+ return nil, fmt.Errorf("decode NetworkSettings error: %s", err)
+ }
+ if networkConfig.Header.Type == "http" {
+ t.Type = networkConfig.Header.Type
+ if networkConfig.Header.Request != nil {
+ var request HttpRequest
+ err = json.Unmarshal(*networkConfig.Header.Request, &request)
+ if err != nil {
+ return nil, fmt.Errorf("decode HttpRequest error: %s", err)
+ }
+ t.HTTPOptions.Host = request.Headers.Host
+ if len(request.Path) > 0 {
+ t.HTTPOptions.Path = request.Path[0]
+ }
+ t.HTTPOptions.Method = request.Method
+ }
+ } else {
+ t.Type = ""
+ }
+ } else {
+ t.Type = ""
+ }
+ case "ws":
+ var (
+ path string
+ ed int
+ headers map[string]badoption.Listable[string]
+ )
+ if len(settings) != 0 {
+ var networkConfig WsNetworkConfig
+ err := json.Unmarshal(settings, &networkConfig)
+ if err != nil {
+ return nil, fmt.Errorf("decode NetworkSettings error: %s", err)
+ }
+ u, err := url.Parse(networkConfig.Path)
+ if err != nil {
+ return nil, fmt.Errorf("parse WS path error: %s", err)
+ }
+ path = u.Path
+ ed, _ = strconv.Atoi(u.Query().Get("ed"))
+ if len(networkConfig.Headers) > 0 {
+ headers = make(map[string]badoption.Listable[string], len(networkConfig.Headers))
+ for k, v := range networkConfig.Headers {
+ headers[k] = badoption.Listable[string]{v}
+ }
+ }
+ }
+ t.WebsocketOptions = option.V2RayWebsocketOptions{
+ Path: path,
+ EarlyDataHeaderName: "Sec-WebSocket-Protocol",
+ MaxEarlyData: uint32(ed),
+ Headers: headers,
+ }
+ case "grpc":
+ if len(settings) != 0 {
+ var networkConfig GrpcNetworkConfig
+ err := json.Unmarshal(settings, &networkConfig)
+ if err != nil {
+ return nil, fmt.Errorf("decode gRPC settings error: %s", err)
+ }
+ t.GRPCOptions = option.V2RayGRPCOptions{
+ ServiceName: networkConfig.ServiceName,
+ }
+ if serviceName := readNetworkString(rawSettings, "service_name"); serviceName != "" && t.GRPCOptions.ServiceName == "" {
+ t.GRPCOptions.ServiceName = serviceName
+ }
+ if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok {
+ t.GRPCOptions.IdleTimeout = idleTimeout
+ }
+ if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok {
+ t.GRPCOptions.PingTimeout = pingTimeout
+ }
+ if permitWithoutStream, ok := readNetworkBool(rawSettings, "permit_without_stream", "permitWithoutStream"); ok {
+ t.GRPCOptions.PermitWithoutStream = permitWithoutStream
+ }
+ }
+ case "httpupgrade":
+ if len(settings) != 0 {
+ var networkConfig HttpupgradeNetworkConfig
+ err := json.Unmarshal(settings, &networkConfig)
+ if err != nil {
+ return nil, fmt.Errorf("decode HttpUpgrade settings error: %s", err)
+ }
+ t.HTTPUpgradeOptions = option.V2RayHTTPUpgradeOptions{
+ Path: networkConfig.Path,
+ Host: networkConfig.Host,
+ }
+ }
+ case "h2", "http":
+ t.Type = "http"
+ if rawSettings != nil {
+ t.HTTPOptions = option.V2RayHTTPOptions{
+ Path: readNetworkString(rawSettings, "path"),
+ Method: readNetworkString(rawSettings, "method"),
+ Headers: nil,
+ }
+ if idleTimeout, ok := readNetworkDuration(rawSettings, "idle_timeout", "idleTimeout"); ok {
+ t.HTTPOptions.IdleTimeout = idleTimeout
+ }
+ if pingTimeout, ok := readNetworkDuration(rawSettings, "ping_timeout", "pingTimeout"); ok {
+ t.HTTPOptions.PingTimeout = pingTimeout
+ }
+ if hostValue, exists := rawSettings["host"]; exists {
+ switch typedHost := hostValue.(type) {
+ case string:
+ if typedHost != "" {
+ t.HTTPOptions.Host = badoption.Listable[string]{typedHost}
+ }
+ case []any:
+ hostList := make(badoption.Listable[string], 0, len(typedHost))
+ for _, item := range typedHost {
+ if host, ok := item.(string); ok && host != "" {
+ hostList = append(hostList, host)
+ }
+ }
+ if len(hostList) > 0 {
+ t.HTTPOptions.Host = hostList
+ }
+ }
+ }
+ if headersValue, exists := rawSettings["headers"]; exists {
+ if headersMap, ok := headersValue.(map[string]any); ok {
+ headerOptions := make(badoption.HTTPHeader)
+ for headerKey, headerValue := range headersMap {
+ switch typedHeader := headerValue.(type) {
+ case string:
+ if typedHeader != "" {
+ headerOptions[headerKey] = badoption.Listable[string]{typedHeader}
+ }
+ case []any:
+ values := make(badoption.Listable[string], 0, len(typedHeader))
+ for _, item := range typedHeader {
+ if headerString, ok := item.(string); ok && headerString != "" {
+ values = append(values, headerString)
+ }
+ }
+ if len(values) > 0 {
+ headerOptions[headerKey] = values
+ }
+ }
+ }
+ if len(headerOptions) > 0 {
+ t.HTTPOptions.Headers = headerOptions
+ }
+ }
+ }
+ }
+ }
+ return t, nil
+}
+
+func networkAcceptProxyProtocol(settings json.RawMessage) bool {
+ if len(settings) == 0 {
+ return false
+ }
+ var raw map[string]any
+ if err := json.Unmarshal(settings, &raw); err != nil {
+ return false
+ }
+ for _, key := range []string{"acceptProxyProtocol", "accept_proxy_protocol"} {
+ value, exists := raw[key]
+ if !exists {
+ continue
+ }
+ enabled, ok := value.(bool)
+ if ok && enabled {
+ return true
+ }
+ }
+ return false
+}
+
+func acceptProxyProtocolEnabled(inner XInnerConfig, config *XNodeConfig) bool {
+ if inner.AcceptProxyProtocol || inner.AcceptProxyProtocol_ {
+ return true
+ }
+ if config != nil && (config.AcceptProxyProtocol || config.AcceptProxyProtocol_) {
+ return true
+ }
+ if networkAcceptProxyProtocol(inner.NetworkSettings) || networkAcceptProxyProtocol(inner.NetworkSettings_) {
+ return true
+ }
+ if config != nil && (networkAcceptProxyProtocol(config.NetworkSettings) || networkAcceptProxyProtocol(config.NetworkSettings_)) {
+ return true
+ }
+ return false
+}
+
+const (
+ defaultXboardTCPKeepAlive = 30 * time.Second
+ defaultXboardTCPKeepAliveInterval = 15 * time.Second
+)
+
+func resolveTCPKeepAlive(inner XInnerConfig, config *XNodeConfig, settings json.RawMessage) (bool, badoption.Duration, badoption.Duration) {
+ disableKeepAlive := inner.DisableTCPKeepAlive || inner.DisableTCPKeepAlive_
+ if !disableKeepAlive && config != nil {
+ disableKeepAlive = config.DisableTCPKeepAlive || config.DisableTCPKeepAlive_
+ }
+ if !disableKeepAlive {
+ if networkDisable, ok := readNetworkBool(unmarshalNetworkSettings(settings), "disable_tcp_keep_alive", "disableTcpKeepAlive"); ok {
+ disableKeepAlive = networkDisable
+ }
+ }
+ if disableKeepAlive {
+ return true, 0, 0
+ }
+
+ keepAlive := inner.TCPKeepAlive
+ if keepAlive == 0 {
+ keepAlive = inner.TCPKeepAlive_
+ }
+ if keepAlive == 0 && config != nil {
+ keepAlive = config.TCPKeepAlive
+ }
+ if keepAlive == 0 && config != nil {
+ keepAlive = config.TCPKeepAlive_
+ }
+ if keepAlive == 0 {
+ if networkKeepAlive, ok := readNetworkDuration(unmarshalNetworkSettings(settings), "tcp_keep_alive", "tcpKeepAlive"); ok {
+ keepAlive = networkKeepAlive
+ }
+ }
+ if keepAlive == 0 {
+ keepAlive = badoption.Duration(defaultXboardTCPKeepAlive)
+ }
+
+ keepAliveInterval := inner.TCPKeepAliveInterval
+ if keepAliveInterval == 0 {
+ keepAliveInterval = inner.TCPKeepAliveInterval_
+ }
+ if keepAliveInterval == 0 && config != nil {
+ keepAliveInterval = config.TCPKeepAliveInterval
+ }
+ if keepAliveInterval == 0 && config != nil {
+ keepAliveInterval = config.TCPKeepAliveInterval_
+ }
+ if keepAliveInterval == 0 {
+ if networkKeepAliveInterval, ok := readNetworkDuration(unmarshalNetworkSettings(settings), "tcp_keep_alive_interval", "tcpKeepAliveInterval"); ok {
+ keepAliveInterval = networkKeepAliveInterval
+ }
+ }
+ if keepAliveInterval == 0 {
+ keepAliveInterval = badoption.Duration(defaultXboardTCPKeepAliveInterval)
+ }
+
+ return false, keepAlive, keepAliveInterval
+}
+
+func (s *Service) setupNode() error {
+ s.logger.Info("Xboard fetching node config...")
+ config, err := s.fetchConfig()
+ if err != nil {
+ return err
+ }
+
+ inboundTag := s.inboundTag()
+
+ // Resolve nested config (V2bX compatibility: server_config / serverConfig / config)
+ var inner XInnerConfig
+ if len(config.ServerConfig) > 0 {
+ json.Unmarshal(config.ServerConfig, &inner)
+ } else if len(config.ServerConfig_) > 0 {
+ json.Unmarshal(config.ServerConfig_, &inner)
+ } else if len(config.Config) > 0 {
+ json.Unmarshal(config.Config, &inner)
+ }
+
+ // Fallback flat fields from top-level config to inner
+ if inner.ListenIP == "" {
+ inner.ListenIP = config.ListenIP
+ }
+ if inner.ListenIP == "" {
+ inner.ListenIP = "0.0.0.0"
+ }
+ if inner.TLSSettings == nil {
+ inner.TLSSettings = config.TLSSettings
+ }
+ if inner.TLSSettings == nil {
+ inner.TLSSettings = config.TLSSettings_
+ }
+ if inner.TLSSettings_ == nil {
+ inner.TLSSettings_ = config.TLSSettings_
+ }
+ if inner.TLS == 0 {
+ inner.TLS = config.TLS
+ }
+ if inner.Flow == "" {
+ inner.Flow = config.Flow
+ }
+ if inner.Protocol == "" {
+ inner.Protocol = config.Protocol
+ }
+ if inner.Protocol == "" {
+ inner.Protocol = config.NodeType
+ }
+ portSource := ""
+ if inner.Port == 0 {
+ if inner.ServerPort != 0 {
+ inner.Port = inner.ServerPort
+ portSource = "inner.server_port"
+ } else if config.Port != 0 {
+ inner.Port = config.Port
+ portSource = "config.port"
+ } else {
+ inner.Port = config.ServerPort
+ if config.ServerPort != 0 {
+ portSource = "config.server_port"
+ }
+ }
+ } else {
+ portSource = "inner.port"
+ }
+ if inner.Cipher == "" {
+ inner.Cipher = config.Cipher
+ }
+ if inner.ServerKey == "" {
+ inner.ServerKey = config.ServerKey
+ }
+ if inner.Network == "" {
+ inner.Network = config.Network
+ }
+ if inner.Multiplex == nil {
+ inner.Multiplex = config.Multiplex
+ }
+ if len(inner.NetworkSettings) == 0 {
+ inner.NetworkSettings = config.NetworkSettings
+ }
+ if len(inner.NetworkSettings_) == 0 {
+ inner.NetworkSettings_ = config.NetworkSettings_
+ }
+ if inner.CertConfig == nil {
+ inner.CertConfig = config.CertConfig
+ }
+ if !inner.AutoTLS {
+ inner.AutoTLS = config.AutoTLS
+ }
+ if inner.Domain == "" {
+ inner.Domain = config.Domain
+ }
+
+ // Resolve protocol
+ protocol := inner.Protocol
+ if protocol == "" {
+ protocol = config.Protocol
+ }
+ if protocol == "" {
+ protocol = inner.NodeType
+ }
+ if protocol == "" {
+ protocol = config.NodeType
+ }
+ if protocol == "" && inner.Cipher != "" {
+ // Fallback for shadowsocks where protocol might be missing but cipher is present
+ protocol = "shadowsocks"
+ }
+
+ if protocol == "" {
+ s.logger.Error("Xboard setup error: could not identify protocol. Please check debug logs for raw JSON.")
+ return fmt.Errorf("unsupported protocol: empty")
+ }
+ if inner.Port == 0 {
+ return fmt.Errorf("missing listen port for protocol %s", protocol)
+ }
+
+ s.logger.Info("Xboard protocol identified: ", protocol)
+ s.protocol = protocol
+ s.vlessFlow = ""
+ s.vlessServerName = ""
+
+ var listenAddr badoption.Addr
+ if addr, err := netip.ParseAddr(inner.ListenIP); err == nil {
+ listenAddr = badoption.Addr(addr)
+ } else {
+ listenAddr = badoption.Addr(netip.IPv4Unspecified())
+ }
+ tcpNetworkSettings := inner.NetworkSettings
+ if len(tcpNetworkSettings) == 0 {
+ tcpNetworkSettings = inner.NetworkSettings_
+ }
+
+ listen := option.ListenOptions{
+ Listen: &listenAddr,
+ ListenPort: uint16(inner.Port),
+ }
+ disableTCPKeepAlive, tcpKeepAlive, tcpKeepAliveInterval := resolveTCPKeepAlive(inner, config, tcpNetworkSettings)
+ listen.DisableTCPKeepAlive = disableTCPKeepAlive
+ if !disableTCPKeepAlive {
+ listen.TCPKeepAlive = tcpKeepAlive
+ listen.TCPKeepAliveInterval = tcpKeepAliveInterval
+ s.logger.Info(
+ "Xboard TCP keepalive configured. idle=", time.Duration(tcpKeepAlive),
+ ", interval=", time.Duration(tcpKeepAliveInterval),
+ )
+ } else {
+ s.logger.Warn("Xboard TCP keepalive disabled by panel config")
+ }
+ if acceptProxyProtocolEnabled(inner, config) {
+ listen.ProxyProtocol = true
+ s.logger.Info("Xboard PROXY protocol enabled for inbound on ", inner.ListenIP, ":", inner.Port)
+ }
+
+ // ── TLS / Reality handling (matching V2bX panel.Security constants) ──
+ // V2bX: 0=None, 1=TLS, 2=Reality
+ var tlsOptions option.InboundTLSOptions
+ securityType := inner.TLS
+ tlsSettings := mergedTLSSettings(inner, config)
+ if tlsSettings != nil && tlsSettings.ServerName != "" {
+ s.vlessServerName = tlsSettings.ServerName
+ }
+ certConfig := mergedCertConfig(inner, config)
+ hasCertificate := applyCertConfig(&tlsOptions, certConfig)
+ configDomain := ""
+ configAutoTLS := false
+ if config != nil {
+ configDomain = config.Domain
+ configAutoTLS = config.AutoTLS
+ }
+ certDomain := ""
+ if certConfig != nil {
+ certDomain = certConfig.Domain
+ }
+ autoTLSDomain := firstNonEmpty(inner.Domain, configDomain, certDomain)
+ if autoTLSDomain == "" && tlsSettings != nil {
+ autoTLSDomain = tlsSettings.ServerName
+ }
+ hasACME := false
+ acmeReason := ""
+ if !hasCertificate {
+ hasACME, acmeReason = applyACMEConfigDetailed(&tlsOptions, certConfig, inner.AutoTLS || configAutoTLS, autoTLSDomain, inner.Port)
+ }
+ if certConfig != nil && !hasCertificate && !hasACME && certConfig.CertMode != "" && certConfig.CertMode != "none" {
+ s.logger.Warn("Xboard cert_config present but unsupported or incomplete for local TLS. cert_mode=", certConfig.CertMode, ", reason=", acmeReason)
+ }
+ if hasACME {
+ s.logger.Info("Xboard ACME configured for domain ", autoTLSDomain)
+ }
+
+ switch securityType {
+ case 1: // TLS
+ if tlsSettings != nil {
+ tlsOptions.ServerName = tlsSettings.ServerName
+ }
+ tlsOptions.Enabled = hasCertificate
+ case 2: // Reality
+ tlsOptions.Enabled = true
+ tlsOptions.ServerName = tlsSettings.ServerName
+ if tlsSettings.ServerName == "" {
+ s.logger.Warn("Xboard REALITY server_name is empty; clients may fail validation")
+ }
+ if tlsSettings.PrivateKey == "" {
+ s.logger.Warn("Xboard REALITY private_key is empty")
+ }
+ shortIDs := tlsSettings.ShortIDs
+ if len(shortIDs) == 0 && tlsSettings.ShortID != "" {
+ shortIDs = []string{tlsSettings.ShortID}
+ }
+ if len(shortIDs) == 0 {
+ s.logger.Warn("Xboard REALITY short_id is empty; falling back to empty short_id")
+ }
+ dest := tlsSettings.Dest
+ if dest == "" {
+ dest = tlsSettings.ServerName
+ }
+ if dest == "" {
+ dest = "www.microsoft.com"
+ }
+ serverPort := uint16(443)
+ if tlsSettings.ServerPort != "" {
+ if port, err := strconv.Atoi(tlsSettings.ServerPort); err == nil && port > 0 {
+ serverPort = uint16(port)
+ }
+ }
+ tlsOptions.Reality = &option.InboundRealityOptions{
+ Enabled: true,
+ Handshake: option.InboundRealityHandshakeOptions{
+ ServerOptions: option.ServerOptions{
+ Server: dest,
+ ServerPort: serverPort,
+ },
+ },
+ PrivateKey: tlsSettings.PrivateKey,
+ ShortID: badoption.Listable[string](shortIDs),
+ }
+ if tlsSettings.PublicKey != "" {
+ s.logger.Debug("Xboard REALITY public_key received from panel")
+ }
+ s.logger.Info(
+ "Xboard REALITY configured. server_name=", tlsSettings.ServerName,
+ ", dest=", dest,
+ ", server_port=", serverPort,
+ ", short_id_count=", len(shortIDs),
+ )
+ }
+
+ // Also check streamSettings for Reality (legacy Xboard format)
+ if inner.StreamSettings != nil && securityType == 0 {
+ var streamSettings XStreamSettings
+ json.Unmarshal(inner.StreamSettings, &streamSettings)
+ reality := streamSettings.GetReality()
+ if streamSettings.Security == "reality" && reality != nil {
+ serverNames := reality.GetServerNames()
+ serverName := ""
+ if len(serverNames) > 0 {
+ serverName = serverNames[0]
+ }
+ tlsOptions = option.InboundTLSOptions{
+ Enabled: true,
+ ServerName: serverName,
+ Reality: &option.InboundRealityOptions{
+ Enabled: true,
+ Handshake: option.InboundRealityHandshakeOptions{
+ ServerOptions: option.ServerOptions{
+ Server: reality.Dest,
+ ServerPort: 443,
+ },
+ },
+ PrivateKey: reality.GetPrivateKey(),
+ ShortID: badoption.Listable[string](reality.GetShortIds()),
+ },
+ }
+ securityType = 2
+ s.logger.Info("Xboard REALITY config from streamSettings")
+ }
+ }
+
+ if securityType == 1 && !tlsOptions.Enabled && !hasUsableServerTLS(tlsOptions) {
+ s.logger.Warn("Xboard TLS enabled by panel but no usable certificate material found; inbound will run without local TLS")
+ }
+
+ // ── Resolve network transport settings (V2bX style) ──
+ networkType := inner.Network
+ networkSettings := inner.NetworkSettings
+ if len(networkSettings) == 0 {
+ networkSettings = inner.NetworkSettings_
+ }
+
+ // ── Build inbound per protocol (matching V2bX core/sing/node.go) ──
+ multiplex := buildInboundMultiplex(inner.Multiplex)
+ s.logger.Info(
+ "Xboard node config resolved. protocol=", protocol,
+ ", listen_ip=", inner.ListenIP,
+ ", listen_port=", inner.Port,
+ " (source=", portSource, ")",
+ ", inner_server_port=", inner.ServerPort,
+ ", config_port=", config.Port,
+ ", config_server_port=", config.ServerPort,
+ ", network=", networkType,
+ ", tls=", securityType,
+ ", multiplex=", multiplex != nil,
+ )
+
+ var inboundOptions any
+ switch protocol {
+ case "vmess", "vless":
+ // Transport for vmess/vless
+ transport, err := getInboundTransport(networkType, networkSettings)
+ if err != nil {
+ return fmt.Errorf("build transport for %s: %w", protocol, err)
+ }
+
+ if protocol == "vless" {
+ if tlsSettings != nil && tlsSettings.ServerName != "" {
+ s.logger.Info("Xboard VLESS server_name from panel: ", tlsSettings.ServerName)
+ }
+ resolvedFlow := inner.Flow
+ if resolvedFlow == "xtls-rprx-vision" {
+ if !tlsOptions.Enabled || (transport != nil && transport.Type != "") {
+ s.logger.Warn("Xboard VLESS flow xtls-rprx-vision ignored because inbound is not raw TLS/REALITY over TCP")
+ resolvedFlow = ""
+ }
+ }
+ s.vlessFlow = resolvedFlow
+ opts := &option.VLESSInboundOptions{
+ ListenOptions: listen,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &tlsOptions,
+ },
+ Multiplex: multiplex,
+ }
+ if transport != nil {
+ opts.Transport = transport
+ }
+ inboundOptions = opts
+ } else {
+ opts := &option.VMessInboundOptions{
+ ListenOptions: listen,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &tlsOptions,
+ },
+ Multiplex: multiplex,
+ }
+ if transport != nil {
+ opts.Transport = transport
+ }
+ inboundOptions = opts
+ }
+ case "shadowsocks":
+ method := inner.Cipher
+ serverKey := inner.ServerKey
+ if serverKey == "" {
+ serverKey = s.options.Key
+ }
+ if method == "" {
+ method = "aes-256-gcm"
+ }
+
+ // Store cipher for user key derivation in syncUsers
+ s.ssCipher = method
+ s.ssServerKey = serverKey
+
+ // V2bX approach: use server_key from panel DIRECTLY as PSK
+ // The panel provides it already in the correct format (base64 for 2022)
+ ssOptions := &option.ShadowsocksInboundOptions{
+ ListenOptions: listen,
+ Method: method,
+ Multiplex: multiplex,
+ }
+
+ isSS2022 := strings.Contains(method, "2022")
+ var dummyKey string
+ if isSS2022 {
+ ssOptions.Password = serverKey
+ dummyBytes := make([]byte, ss2022KeyLength(method))
+ for i := range dummyBytes {
+ dummyBytes[i] = byte(rand.Intn(256))
+ }
+ dummyKey = base64.StdEncoding.EncodeToString(dummyBytes)
+ } else {
+ dummyKey = "dummy_user_key"
+ }
+
+ ssOptions.Users = []option.ShadowsocksUser{{
+ Password: dummyKey,
+ }}
+
+ if isSS2022 {
+ s.logger.Info("Xboard SS2022 setup. Method: ", method, " Master_PSK: ", ssOptions.Password)
+ } else {
+ // Legacy SS: password-based
+ ssOptions.Password = serverKey
+ s.logger.Info("Xboard Shadowsocks setup. Method: ", method)
+ }
+
+ inboundOptions = ssOptions
+ case "trojan":
+ if !hasUsableServerTLS(tlsOptions) {
+ return fmt.Errorf("trojan requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain")
+ }
+ // Trojan supports ws/grpc transport like V2bX
+ transport, err := getInboundTransport(networkType, networkSettings)
+ if err != nil {
+ return fmt.Errorf("build transport for trojan: %w", err)
+ }
+
+ opts := &option.TrojanInboundOptions{
+ ListenOptions: listen,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &tlsOptions,
+ },
+ Multiplex: multiplex,
+ }
+ if transport != nil {
+ opts.Transport = transport
+ }
+ inboundOptions = opts
+ case "tuic":
+ if !hasUsableServerTLS(tlsOptions) {
+ return fmt.Errorf("tuic requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain")
+ }
+ // V2bX: TUIC always uses TLS with h3 ALPN
+ tuicTLS := tlsOptions
+ tuicTLS.Enabled = true
+ tuicTLS.ALPN = append(tuicTLS.ALPN, "h3")
+
+ congestionControl := config.CongestionControl
+ if congestionControl == "" {
+ congestionControl = "bbr"
+ }
+
+ opts := &option.TUICInboundOptions{
+ ListenOptions: listen,
+ CongestionControl: congestionControl,
+ ZeroRTTHandshake: config.ZeroRTTHandshake,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &tuicTLS,
+ },
+ }
+ inboundOptions = opts
+ s.logger.Info("Xboard TUIC configured. CongestionControl: ", congestionControl)
+ case "hysteria":
+ if !hasUsableServerTLS(tlsOptions) {
+ return fmt.Errorf("hysteria requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain")
+ }
+ // V2bX: Hysteria always uses TLS
+ hyTLS := tlsOptions
+ hyTLS.Enabled = true
+
+ opts := &option.HysteriaInboundOptions{
+ ListenOptions: listen,
+ UpMbps: config.UpMbps,
+ DownMbps: config.DownMbps,
+ Obfs: config.Obfs,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &hyTLS,
+ },
+ }
+ inboundOptions = opts
+ s.logger.Info("Xboard Hysteria configured. Up: ", config.UpMbps, " Down: ", config.DownMbps)
+ case "hysteria2":
+ if !hasUsableServerTLS(tlsOptions) {
+ return fmt.Errorf("hysteria2 requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain")
+ }
+ // V2bX: Hysteria2 always uses TLS, optional obfs
+ hy2TLS := tlsOptions
+ hy2TLS.Enabled = true
+
+ var obfs *option.Hysteria2Obfs
+ if config.Obfs != "" && config.ObfsPassword != "" {
+ obfs = &option.Hysteria2Obfs{
+ Type: config.Obfs,
+ Password: config.ObfsPassword,
+ }
+ } else if config.Obfs != "" {
+ // V2bX compat: if only obfs type given, treat as salamander with obfs as password
+ obfs = &option.Hysteria2Obfs{
+ Type: "salamander",
+ Password: config.Obfs,
+ }
+ }
+
+ opts := &option.Hysteria2InboundOptions{
+ ListenOptions: listen,
+ UpMbps: config.UpMbps,
+ DownMbps: config.DownMbps,
+ IgnoreClientBandwidth: config.Ignore_Client_Bandwidth,
+ Obfs: obfs,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &hy2TLS,
+ },
+ }
+ inboundOptions = opts
+ s.logger.Info("Xboard Hysteria2 configured. Up: ", config.UpMbps, " Down: ", config.DownMbps, " IgnoreClientBW: ", config.Ignore_Client_Bandwidth)
+ case "anytls":
+ if !hasUsableServerTLS(tlsOptions) {
+ return fmt.Errorf("anytls requires local TLS certificate or ACME; configure cert_mode file/content/http/dns or auto_tls with domain")
+ }
+ // V2bX: AnyTLS always uses TLS
+ anyTLS := tlsOptions
+ anyTLS.Enabled = true
+
+ opts := &option.AnyTLSInboundOptions{
+ ListenOptions: listen,
+ PaddingScheme: config.PaddingScheme,
+ InboundTLSOptionsContainer: option.InboundTLSOptionsContainer{
+ TLS: &anyTLS,
+ },
+ }
+ inboundOptions = opts
+ s.logger.Info("Xboard AnyTLS configured")
+ default:
+ return fmt.Errorf("unsupported protocol: %s", protocol)
+ }
+
+ if !tlsOptions.Enabled && securityType == 1 && (protocol == "trojan" || protocol == "tuic" || protocol == "hysteria" || protocol == "hysteria2" || protocol == "anytls") {
+ s.logger.Warn("Xboard ", protocol, " usually requires local TLS certificates, but no local certificate material was configured")
+ }
+
+ // Remove old if exists
+ s.inboundManager.Remove(inboundTag)
+ s.logger.Info("Xboard creating inbound [", inboundTag, "] on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")")
+
+ // Create new inbound
+ err = s.inboundManager.Create(s.ctx, s.router, s.logger, inboundTag, protocol, inboundOptions)
+ if err != nil {
+ return err
+ }
+
+ s.access.Lock()
+ s.inboundTags = []string{inboundTag}
+ s.access.Unlock()
+
+ s.logger.Info("Xboard dynamic inbound [", inboundTag, "] created on ", inner.ListenIP, ":", inner.Port, " (protocol: ", protocol, ")")
+
+ // Register the new inbound in our managed list
+ inbound, _ := s.inboundManager.Get(inboundTag)
+ managedServer, isManaged := inbound.(adapter.ManagedSSMServer)
+ if isManaged {
+ tracker := newXboardTracker(s)
+ traffic := tracker.TrafficManager()
+ managedServer.SetTracker(tracker)
+ user := ssmapi.NewUserManager(managedServer, traffic)
+
+ s.access.Lock()
+ s.traffics[inboundTag] = traffic
+ s.users[inboundTag] = user
+ s.servers[inboundTag] = managedServer
+ s.inboundTags = []string{inboundTag}
+ s.access.Unlock()
+
+ s.logger.Info("Xboard managed inbound [", inboundTag, "] registered (protocol: ", protocol, ")")
+ }
+
+ return nil
+}
+
+func normalizePanelNodeType(nodeType string) string {
+ switch strings.ToLower(strings.TrimSpace(nodeType)) {
+ case "v2ray":
+ return "vmess"
+ case "hysteria2":
+ return "hysteria"
+ default:
+ return strings.ToLower(strings.TrimSpace(nodeType))
+ }
+}
+
+func (s *Service) panelRequest(method string, baseURL string, endpoint string, nodeID int, payload []byte, contentType string) (http.Header, []byte, int, error) {
+ nodeType := normalizePanelNodeType(s.options.NodeType)
+ nodeTypeCandidates := []string{""}
+ if nodeType != "" {
+ nodeTypeCandidates = append(nodeTypeCandidates, nodeType)
+ }
+
+ var lastHeader http.Header
+ var lastBody []byte
+ var lastStatus int
+ for index, candidate := range nodeTypeCandidates {
+ requestURL, err := url.Parse(strings.TrimRight(baseURL, "/") + endpoint)
+ if err != nil {
+ return nil, nil, 0, err
+ }
+ query := requestURL.Query()
+ query.Set("node_id", strconv.Itoa(nodeID))
+ query.Set("token", s.options.Key)
+ if candidate != "" {
+ query.Set("node_type", candidate)
+ }
+ requestURL.RawQuery = query.Encode()
+
+ var bodyReader io.Reader
+ if payload != nil {
+ bodyReader = bytes.NewReader(payload)
+ }
+ req, _ := http.NewRequest(method, requestURL.String(), bodyReader)
+ req.Header.Set("User-Agent", "sing-box/xboard")
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+
+ logNodeType := candidate
+ if logNodeType == "" {
+ logNodeType = ""
+ }
+ s.logger.Info("Xboard panel request. endpoint=", endpoint, ", node_id=", nodeID, ", node_type=", logNodeType)
+
+ resp, err := s.httpClient.Do(req)
+ if err != nil {
+ return nil, nil, 0, err
+ }
+ responseBody, readErr := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if readErr != nil {
+ return nil, nil, 0, readErr
+ }
+
+ lastHeader = resp.Header.Clone()
+ lastBody = responseBody
+ lastStatus = resp.StatusCode
+
+ if resp.StatusCode == 400 && candidate == "" && strings.Contains(string(responseBody), "Server does not exist") && index+1 < len(nodeTypeCandidates) {
+ s.logger.Warn("Xboard panel request failed without node_type, retrying with configured node_type=", nodeType)
+ continue
+ }
+
+ return lastHeader, lastBody, lastStatus, nil
+ }
+
+ return lastHeader, lastBody, lastStatus, nil
+}
+
+func (s *Service) fetchConfig() (*XNodeConfig, error) {
+ nodeID := s.options.ConfigNodeID
+ if nodeID == 0 {
+ nodeID = s.options.NodeID
+ }
+ baseURL := s.options.ConfigPanelURL
+ if baseURL == "" {
+ baseURL = s.options.PanelURL
+ }
+ headers, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/config", nodeID, nil, "")
+ if err != nil {
+ return nil, err
+ }
+
+ // Check time drift
+ if dateStr := headers.Get("Date"); dateStr != "" {
+ if panelTime, err := http.ParseTime(dateStr); err == nil {
+ localTime := time.Now()
+ drift := localTime.Sub(panelTime)
+ if drift < 0 {
+ drift = -drift
+ }
+ s.logger.Info("TIME CHECK: Panel Local [", panelTime.Format(time.RFC3339), "] vs Server Local [", localTime.Format(time.RFC3339), "]. Drift: ", drift)
+ if drift > 30*time.Second {
+ s.logger.Error("CRITICAL TIME DRIFT: Your server time is OUT OF SYNC. Shadowsocks 2022 WILL FAIL!")
+ }
+ }
+ }
+
+ if statusCode != 200 {
+ return nil, E.New("failed to fetch config, status: ", statusCode, ", body: ", string(body))
+ }
+
+ var result struct {
+ Data XNodeConfig `json:"data"`
+ }
+ err = json.Unmarshal(body, &result)
+ if err != nil || (result.Data.Protocol == "" && result.Data.NodeType == "" && result.Data.ServerPort == 0) {
+ // Try unmarshaling WITHOUT "data" wrapper
+ var flatResult XNodeConfig
+ if err2 := json.Unmarshal(body, &flatResult); err2 == nil {
+ s.logger.Info(
+ "Xboard config fetched (flat). protocol=", flatResult.Protocol,
+ ", node_type=", flatResult.NodeType,
+ ", port=", flatResult.Port,
+ ", server_port=", flatResult.ServerPort,
+ )
+ return &flatResult, nil
+ }
+
+ s.logger.Error("Xboard decoder error: ", err)
+ s.logger.Error("Xboard raw config response: ", string(body))
+ return nil, err
+ }
+
+ // Final safety check
+ if result.Data.Protocol == "" && len(result.Data.ServerConfig) == 0 && len(result.Data.Config) == 0 && result.Data.NodeType == "" && result.Data.ServerPort == 0 {
+ s.logger.Error("Xboard config mapping failed (fields missing). Data: ", string(body))
+ }
+ s.logger.Info(
+ "Xboard config fetched. protocol=", result.Data.Protocol,
+ ", node_type=", result.Data.NodeType,
+ ", port=", result.Data.Port,
+ ", server_port=", result.Data.ServerPort,
+ )
+
+ return &result.Data, nil
+}
+
+func (s *Service) loop() {
+ // Initial sync
+ s.syncUsers()
+
+ for {
+ select {
+ case <-s.ctx.Done():
+ return
+ case <-s.syncTicker.C:
+ s.syncUsers()
+ case <-s.reportTicker.C:
+ s.reportTraffic()
+ case <-s.aliveTicker.C:
+ s.sendAlive()
+ }
+ }
+}
+
+type userData struct {
+ ID int
+ Email string
+ Key string
+ Flow string
+}
+
+func (s *Service) syncUsers() {
+ s.logger.Info("Xboard sync users...")
+ users, err := s.fetchUsers()
+ if err != nil {
+ s.logger.Error("Xboard sync error: ", err)
+ return
+ }
+
+ if len(users) == 0 {
+ s.logger.Warn("Xboard sync: no users returned from panel. Check your Node ID and User config.")
+ }
+
+ s.access.Lock()
+ defer s.access.Unlock()
+
+ newUsers := make(map[string]userData)
+ isSS2022 := strings.Contains(s.ssCipher, "2022")
+ ss2022KeyLen := 0
+ if isSS2022 {
+ ss2022KeyLen = ss2022KeyLength(s.ssCipher)
+ }
+
+ for _, u := range users {
+ userName := u.Identifier()
+ if userName == "" {
+ continue
+ }
+ key := s.resolveUserKey(u, isSS2022)
+ if key == "" {
+ continue
+ }
+
+ // V2bX/Xboard approach for SS2022 user key:
+ if isSS2022 {
+ originalKey := key
+ key = ss2022Key(key, ss2022KeyLen)
+ s.logger.Info("User [", u.ID, "] ID[:", ss2022KeyLen, "]=", originalKey, " → b64_PSK=", key)
+ }
+
+ flow := u.Flow
+ if s.protocol == "vless" && flow == "" {
+ flow = s.vlessFlow
+ }
+ if s.protocol == "vless" && flow == "xtls-rprx-vision" && s.vlessServerName == "" {
+ s.logger.Warn("Xboard VLESS flow xtls-rprx-vision kept but panel did not provide server_name")
+ }
+
+ newUsers[userName] = userData{
+ ID: u.ID,
+ Email: userName,
+ Key: key,
+ Flow: flow,
+ }
+ }
+
+ if sameUserSet(s.localUsers, newUsers) {
+ s.logger.Trace("Xboard sync skipped: users unchanged")
+ return
+ }
+
+ for tag, server := range s.servers {
+ // Update users in each manager
+ users := make([]string, 0, len(newUsers))
+ keys := make([]string, 0, len(newUsers))
+ flows := make([]string, 0, len(newUsers))
+ for _, u := range newUsers {
+ users = append(users, u.Email)
+ keys = append(keys, u.Key)
+ flows = append(flows, u.Flow)
+ }
+ err = server.UpdateUsers(users, keys, flows)
+ if err != nil {
+ s.logger.Error("Update users for inbound ", tag, ": ", err)
+ }
+ }
+
+ // Update local ID mapping
+ s.localUsers = newUsers
+
+ s.logger.Info("Xboard sync completed, total users: ", len(users))
+}
+
+func sameUserSet(current map[string]userData, next map[string]userData) bool {
+ if len(current) != len(next) {
+ return false
+ }
+ for userName, currentUser := range current {
+ nextUser, exists := next[userName]
+ if !exists {
+ return false
+ }
+ if currentUser != nextUser {
+ return false
+ }
+ }
+ return true
+}
+
+func (s *Service) reportTraffic() {
+ s.logger.Trace("Xboard reporting traffic...")
+
+ s.access.Lock()
+ localUsers := s.localUsers
+ s.access.Unlock()
+
+ if len(localUsers) == 0 {
+ return
+ }
+
+ usageMap := make(map[string][2]int64)
+
+ for _, trafficManager := range s.traffics {
+ users := make([]*ssmapi.UserObject, 0, len(localUsers))
+ for email := range localUsers {
+ users = append(users, &ssmapi.UserObject{UserName: email})
+ }
+
+ // Read incremental usage
+ trafficManager.ReadUsers(users, true)
+
+ for _, u := range users {
+ if u.UplinkBytes == 0 && u.DownlinkBytes == 0 {
+ continue
+ }
+ meta, ok := localUsers[u.UserName]
+ if !ok {
+ continue
+ }
+ userID := strconv.Itoa(meta.ID)
+ item := usageMap[userID]
+ item[0] += u.UplinkBytes
+ item[1] += u.DownlinkBytes
+ usageMap[userID] = item
+ }
+ }
+
+ if len(usageMap) == 0 {
+ return
+ }
+
+ err := s.pushTraffic(usageMap)
+ if err != nil {
+ s.logger.Error("Xboard report error: ", err)
+ } else {
+ s.logger.Info("Xboard report completed, users reported: ", len(usageMap))
+ }
+}
+
+func (s *Service) pushTraffic(data any) error {
+ nodeID := s.options.UserNodeID
+ if nodeID == 0 {
+ nodeID = s.options.NodeID
+ }
+ baseURL := s.options.UserPanelURL
+ if baseURL == "" {
+ baseURL = s.options.PanelURL
+ }
+ body, _ := json.Marshal(data)
+
+ _, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/push", nodeID, body, "application/json")
+ if err != nil {
+ return err
+ }
+
+ if statusCode != 200 {
+ return E.New("failed to push traffic, status: ", statusCode, ", body: ", string(responseBody))
+ }
+ return nil
+}
+
+func (s *Service) sendAlive() {
+ nodeID := s.options.UserNodeID
+ if nodeID == 0 {
+ nodeID = s.options.ConfigNodeID
+ }
+ if nodeID == 0 {
+ nodeID = s.options.NodeID
+ }
+ baseURL := s.options.UserPanelURL
+ if baseURL == "" {
+ baseURL = s.options.ConfigPanelURL
+ }
+ if baseURL == "" {
+ baseURL = s.options.PanelURL
+ }
+
+ payload := s.buildAlivePayload()
+ body, err := json.Marshal(payload)
+ if err != nil {
+ s.logger.Error("Xboard alive payload error: ", err)
+ return
+ }
+
+ _, responseBody, statusCode, err := s.panelRequest("POST", baseURL, "/api/v1/server/UniProxy/alive", nodeID, body, "application/json")
+ if err != nil {
+ s.logger.Error("Xboard heartbeat error: ", err)
+ return
+ }
+
+ if statusCode != 200 {
+ s.logger.Warn("Xboard heartbeat failed, status: ", statusCode, ", body: ", string(responseBody))
+ } else {
+ s.logger.Trace("Xboard heartbeat sent, users: ", len(payload))
+ }
+}
+
+func (s *Service) Close() error {
+ s.cancel()
+ s.syncTicker.Stop()
+ s.reportTicker.Stop()
+ s.aliveTicker.Stop()
+ return nil
+}
+
+// Xboard User Model
+type XUser struct {
+ ID int `json:"id"`
+ Email string `json:"email"`
+ UUID string `json:"uuid"` // V2ray/Vless
+ Passwd string `json:"passwd"` // SS
+ Password string `json:"password"` // Trojan/SS alternate
+ Token string `json:"token"` // Alternate
+ Flow string `json:"flow"`
+}
+
+func (u *XUser) Identifier() string {
+ if u.UUID != "" {
+ return u.UUID
+ }
+ if u.Email != "" {
+ return u.Email
+ }
+ if u.ID != 0 {
+ return strconv.Itoa(u.ID)
+ }
+ return ""
+}
+
+func (u *XUser) ResolveKey() string {
+ if u.Passwd != "" {
+ return u.Passwd
+ }
+ if u.Password != "" {
+ return u.Password
+ }
+ if u.UUID != "" {
+ return u.UUID
+ }
+ return u.Token
+}
+
+func (s *Service) resolveUserKey(u XUser, isSS2022 bool) string {
+ key := u.ResolveKey()
+ if !isSS2022 || key == "" {
+ return key
+ }
+ if strings.Contains(key, ":") {
+ serverKey, userKey, ok := strings.Cut(key, ":")
+ if ok && userKey != "" {
+ if s.ssServerKey != "" && serverKey != "" && serverKey != s.ssServerKey {
+ s.logger.Warn("Xboard SS2022 user key server key mismatch for user [", u.ID, "]")
+ }
+ return userKey
+ }
+ }
+ return key
+}
+
+func (s *Service) fetchUsers() ([]XUser, error) {
+ nodeID := s.options.UserNodeID
+ if nodeID == 0 {
+ nodeID = s.options.NodeID
+ }
+ baseURL := s.options.UserPanelURL
+ if baseURL == "" {
+ baseURL = s.options.PanelURL
+ }
+ _, body, statusCode, err := s.panelRequest("GET", baseURL, "/api/v1/server/UniProxy/user", nodeID, nil, "")
+ if err != nil {
+ return nil, err
+ }
+
+ if statusCode != 200 {
+ return nil, E.New("failed to fetch users, status: ", statusCode, ", body: ", string(body))
+ }
+
+ var result struct {
+ Data []XUser `json:"data"`
+ Users []XUser `json:"users"`
+ }
+ err = json.Unmarshal(body, &result)
+ if err != nil {
+ s.logger.Error("Xboard raw user response: ", string(body))
+ return nil, err
+ }
+
+ userList := result.Data
+ if len(userList) == 0 {
+ userList = result.Users
+ }
+
+ return userList, nil
+}
diff --git a/service/xboard/service_test.go b/service/xboard/service_test.go
new file mode 100644
index 00000000..3ec11d67
--- /dev/null
+++ b/service/xboard/service_test.go
@@ -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)
+ }
+ }
+}
diff --git a/service/xboard/tracker.go b/service/xboard/tracker.go
new file mode 100644
index 00000000..2a1155c7
--- /dev/null
+++ b/service/xboard/tracker.go
@@ -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
+}