From 66c252d6efaf743d3a240206cf96f2e5ff4547ea Mon Sep 17 00:00:00 2001 From: CN-JS-HuiBai Date: Thu, 16 Apr 2026 10:29:41 +0800 Subject: [PATCH] Reapply SingboxForPanel integration on upstream stable --- README.md | 438 ++++- adapter/ssm.go | 2 +- building-install.sh | 401 ++++ building-linux.sh | 202 ++ building-windows.ps1 | 363 ++++ common/dnspod/provider.go | 332 ++++ common/tls/acme.go | 15 +- configs/10-base.multi-node.json | 50 + configs/10-base.single-node.json | 42 + configs/20-outbounds.example.json | 12 + configs/panel-response.anytls-acme-dns.json | 33 + configs/panel-response.shadowsocks2022.json | 16 + configs/panel-response.vless-reality.json | 21 + constant/dns.go | 29 +- constant/proxy.go | 1 + go.mod | 1 + include/registry.go | 2 + install.sh | 576 ++++++ option/tls_acme.go | 37 +- option/xboard.go | 33 + protocol/anytls/inbound.go | 52 +- protocol/shadowsocks/inbound_multi.go | 38 +- protocol/vless/inbound.go | 68 +- protocol/vmess/inbound.go | 64 +- service/ssmapi/user.go | 2 +- service/xboard/multi_service.go | 112 ++ service/xboard/service.go | 1963 +++++++++++++++++++ service/xboard/service_test.go | 317 +++ service/xboard/tracker.go | 99 + 29 files changed, 5280 insertions(+), 41 deletions(-) create mode 100644 building-install.sh create mode 100644 building-linux.sh create mode 100644 building-windows.ps1 create mode 100644 common/dnspod/provider.go create mode 100644 configs/10-base.multi-node.json create mode 100644 configs/10-base.single-node.json create mode 100644 configs/20-outbounds.example.json create mode 100644 configs/panel-response.anytls-acme-dns.json create mode 100644 configs/panel-response.shadowsocks2022.json create mode 100644 configs/panel-response.vless-reality.json create mode 100644 install.sh create mode 100644 option/xboard.go create mode 100644 service/xboard/multi_service.go create mode 100644 service/xboard/service.go create mode 100644 service/xboard/service_test.go create mode 100644 service/xboard/tracker.go 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 - -Warp sponsorship - +# sing-box Xboard Fork ---- +这是一个基于上游 [SagerNet/sing-box](https://github.com/SagerNet/sing-box) 的定制分支,主要面向 Xboard / UniProxy 面板联动部署场景。 -# sing-box +它保留了上游 `sing-box` 内核能力,同时补充了: -The universal proxy platform. +- Xboard 动态入站服务 +- 多节点托管 +- 面板协议自动识别 +- VLESS / REALITY 面板字段映射 +- Shadowsocks 2022 用户同步与密钥处理 +- AnyTLS / Trojan / Hysteria / TUIC 的本地 TLS / ACME 支持 +- 安装脚本与分离式配置模板 +- PROXY protocol 客户端真实 IP 传递 -[![Packaging status](https://repology.org/badge/vertical-allrepos/sing-box.svg)](https://repology.org/project/sing-box/versions) +如果你要查看官方功能基线、通用配置文档、原始内核行为,请优先参考上游: -## Documentation +- 上游仓库:https://github.com/SagerNet/sing-box +- 上游文档:https://sing-box.sagernet.org -https://sing-box.sagernet.org +## 适用场景 + +这个仓库更适合以下用途: + +- 从 Xboard / UniProxy 面板拉取节点配置 +- 从面板拉取用户并动态更新 +- 单机运行多个节点 +- 给面板型机场节点准备安装脚本 +- 对接面板下发的 `protocol`、`cert_config`、`accept_proxy_protocol`、REALITY 字段等 + +## 主要特性 + +- `services.xboard` 动态服务 +- 支持单节点和多节点 +- 协议优先从面板返回的 `protocol` 识别 +- 支持面板回包控制 `accept_proxy_protocol` +- 支持 VLESS REALITY 的 `server_name`、`public_key`、`private_key`、`short_id` +- 支持 ACME: + - `auto_tls` + - `cert_mode = http` + - `cert_mode = dns` +- 当前已支持的 ACME DNS provider: + - `cloudflare` + - `alidns` + - `tencentcloud` + - `dnspod` + - `acmedns` +- 安装脚本默认生成: + - `/etc/sing-box/config.d/10-base.json` + - `/etc/sing-box/config.d/20-outbounds.json` +- 安装后的服务名为: + - `singbox.service` + +## 仓库内关键文件 + +- [install.sh](./install.sh) + Linux 安装脚本 +- [option/xboard.go](./option/xboard.go) + `services.xboard` 配置结构 +- [service/xboard/service.go](./service/xboard/service.go) + Xboard 动态服务实现 +- [configs](./configs) + 配置示例目录 + +## 快速开始 + +### 1. 编译并安装 + +在 Linux 服务器上进入仓库目录: + +```bash +curl -fsSL https://s3.cloudyun.top/downloads/singbox/install.sh | bash +``` +`install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。 + + +脚本会做这些事情: + +1. 检查 Go 环境 +2. 编译当前仓库代码 +3. 生成分离配置 +4. 创建 `singbox.service` +5. 启动服务 + +### 2. 安装过程会询问的内容 + +- `Panel URL` +- `Panel Token` +- 一个或多个 `Node ID` +- DNS 模式: + - `udp` + - `local` +- 当前节点前面是否有发送 PROXY protocol 的四层代理 + +### 3. 多节点输入规则 + +- 安装脚本会持续要求输入 `Node ID` +- 输入 `NO` 才结束 +- 至少要有一个节点 + +## 运行与管理 + +查看状态: + +```bash +systemctl status singbox +``` + +查看日志: + +```bash +journalctl -u singbox -f +``` + +重启服务: + +```bash +systemctl restart singbox +``` + +手动运行: + +```bash +sing-box -D /var/lib/sing-box -C /etc/sing-box/config.d run +``` + +## 推荐配置结构 + +建议把配置拆成两部分。 + +### `10-base.json` + +放这些内容: + +- 日志 +- DNS +- `services` +- 基础路由规则 + +### `20-outbounds.json` + +放这些内容: + +- 全部出站 +- 出站标签 +- 你自己的分流依赖 + +这样更方便: + +- 面板动态服务和你的自定义出站分离 +- 安装脚本生成的基础配置不容易被误改 +- 调整出站时不会影响 Xboard 服务主体 + +示例已放在: + +- [configs/10-base.single-node.json](./configs/10-base.single-node.json) +- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json) +- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json) + +## `services.xboard` 配置说明 + +配置结构定义见 [option/xboard.go](./option/xboard.go)。 + +### 最小单节点示例 + +```json +{ + "type": "xboard", + "panel_url": "https://panel.example.com", + "key": "replace-with-node-token", + "sync_interval": "1m", + "report_interval": "1m", + "node_id": 286 +} +``` + +### 多节点示例 + +```json +{ + "type": "xboard", + "panel_url": "https://panel.example.com", + "key": "replace-with-node-token", + "sync_interval": "1m", + "report_interval": "1m", + "nodes": [ + { + "node_id": 286 + }, + { + "node_id": 774 + } + ] +} +``` + +### 当前推荐做法 + +- 只传 `panel_url` +- 只传 `key` +- 只传 `node_id` 或 `nodes` + +下面这些字段仍然保留兼容,但常规部署一般不需要: + +- `config_panel_url` +- `user_panel_url` +- `config_node_id` +- `user_node_id` +- `node_type` + +说明: + +- 当前逻辑会优先从面板回包中的 `protocol` 自动识别协议 +- `node_type` 更适合做历史兼容,不建议再依赖它做主配置 + +## 面板配置回包约定 + +### 1. 协议识别 + +推荐面板显式返回: + +```json +{ + "protocol": "vless" +} +``` + +当前服务会按如下顺序识别协议: + +1. `protocol` +2. `node_type` +3. 如果是 Shadowsocks 且存在 `cipher`,则回退识别为 `shadowsocks` + +### 2. 监听地址与端口 + +推荐面板返回: + +```json +{ + "listen_ip": "0.0.0.0", + "server_port": 443 +} +``` + +### 3. PROXY protocol + +如果前面有四层代理,并且它会发送 PROXY protocol 头,需要面板返回: + +```json +{ + "accept_proxy_protocol": true +} +``` + +如果用户是直连节点,不要开启这个字段,否则连接会失败。 + +### 4. VLESS REALITY + +当前支持从面板获取这些字段: + +- `tls_settings.server_name` +- `tls_settings.public_key` +- `tls_settings.private_key` +- `tls_settings.short_id` +- 顶层 `server_name` +- 顶层 `public_key` +- 顶层 `private_key` +- 顶层 `short_id` + +示例见: + +- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json) + +### 5. Shadowsocks 2022 + +推荐面板返回: + +- `protocol: "shadowsocks"` +- `cipher` +- `server_key` + +示例见: + +- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json) + +### 6. AnyTLS / Trojan / Hysteria / TUIC 的证书要求 + +这些协议通常要求本地具备可用 TLS 证书。当前支持: + +- 文件证书 +- 证书内容直传 +- ACME 自动签发 + +如果面板没有下发可用证书,也没有有效 ACME 配置,常见报错是: + +```text +Xboard setup error: missing certificate +``` + +示例见: + +- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json) + +## ACME 说明 + +当前支持: + +- `auto_tls: true` +- `cert_mode: "http"` +- `cert_mode: "dns"` + +### DNS-01 provider + +已支持: + +- `cloudflare` +- `alidns` +- `tencentcloud` +- `dnspod` +- `acmedns` + +### DNSPod 说明 + +仓库已经内置 DNSPod provider 适配,不再依赖旧版 `github.com/libdns/dnspod` 的接口兼容。 + +你可以从面板下发: + +```json +{ + "cert_config": { + "cert_mode": "dns", + "dns_provider": "dnspod", + "dns_env": { + "DNSPOD_TOKEN": "id,token" + } + } +} +``` + +也可以下发腾讯云凭据: + +```json +{ + "cert_config": { + "cert_mode": "dns", + "dns_provider": "tencentcloud", + "dns_env": { + "TENCENTCLOUD_SECRET_ID": "xxx", + "TENCENTCLOUD_SECRET_KEY": "xxx" + } + } +} +``` + +## 配置示例目录 + +[configs](./configs) 目录中已经提供了这些示例: + +- [configs/10-base.single-node.json](./configs/10-base.single-node.json) + 单节点基础配置 +- [configs/10-base.multi-node.json](./configs/10-base.multi-node.json) + 多节点基础配置 +- [configs/20-outbounds.example.json](./configs/20-outbounds.example.json) + 出站配置模板 +- [configs/panel-response.vless-reality.json](./configs/panel-response.vless-reality.json) + VLESS REALITY 面板回包 +- [configs/panel-response.shadowsocks2022.json](./configs/panel-response.shadowsocks2022.json) + Shadowsocks 2022 面板回包 +- [configs/panel-response.anytls-acme-dns.json](./configs/panel-response.anytls-acme-dns.json) + AnyTLS + ACME DNS 验证面板回包 + +## 常见问题 + +### `unsupported protocol: empty` + +原因通常是: + +- 面板没有返回 `protocol` +- 兼容字段 `node_type` 也为空 + +建议: + +- 面板显式返回顶层 `protocol` + +### `Xboard setup error: missing certificate` + +原因通常是: + +- AnyTLS / Trojan / Hysteria / TUIC 需要证书 +- 面板没有下发证书文件、证书内容或 ACME 参数 + +### `TLS handshake: REALITY: processed invalid connection` + +多数情况下表示客户端参数和当前 REALITY 节点配置不匹配,例如: + +- `server_name` 错误 +- `public_key` 错误 +- `short_id` 错误 +- 客户端实际上不是按 REALITY 模式连入 + +### `Server does not exist` + +通常是: + +- 面板里不存在该 `node_id` +- `token` 不匹配 +- 拉取配置或拉取用户的节点 ID 写错 + +### 真实 IP 没有正确获取 + +请确认: + +1. 前置代理确实发送了 PROXY protocol +2. 面板确实下发了 `accept_proxy_protocol: true` + +否则服务只能看到上游代理 IP。 + +## 开发验证 + +近期已验证通过的命令: + +```bash +go test ./common/dnspod ./common/tls ./service/acme ./service/xboard +go build -trimpath -tags 'with_quic,with_utls,with_clash_api,with_gvisor,with_acme' ./cmd/sing-box +``` + +如果你改动了 Xboard、协议映射、ACME 或证书相关逻辑,建议至少执行一次以上命令。 ## License -``` +```text Copyright (C) 2022 by nekohasekai 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 +}