16 Commits
v1.1.2 ... main

Author SHA1 Message Date
CN-JS-HuiBai
58eeb298f2 构建完整的 InboundContext 2026-04-22 02:18:51 +08:00
CN-JS-HuiBai
be0bed90cb 修改服务端逻辑 2026-04-22 01:43:56 +08:00
CN-JS-HuiBai
d5edc111dd 提供完整日志 2026-04-22 01:22:16 +08:00
CN-JS-HuiBai
c7e392a703 尝试修复节点后端 2026-04-22 01:10:22 +08:00
CN-JS-HuiBai
7fb673bc81 修复SBProxy出站的错误问题 2026-04-22 00:40:15 +08:00
CN-JS-HuiBai
30756be55a 重新对接面板接口 2026-04-21 23:54:29 +08:00
CN-JS-HuiBai
a30eee39b2 对齐SBPanel开发颗粒度 2026-04-21 23:14:48 +08:00
CN-JS-HuiBai
fb5c1d5ef2 添加Client安装脚本 2026-04-21 22:23:08 +08:00
CN-JS-HuiBai
32aca2c4e1 安装脚本支持无面板运行 2026-04-21 22:13:48 +08:00
CN-JS-HuiBai
3341a3d5c4 完善SBProxy协议逻辑 2026-04-21 21:26:50 +08:00
CN-JS-HuiBai
ff069d39eb 修复SBProxy-MC的一些问题 2026-04-21 21:11:20 +08:00
CN-JS-HuiBai
39f9a7592c 基本完善SBProxy-MC 2026-04-21 17:15:37 +08:00
CN-JS-HuiBai
5910b7c019 新增协议SBProxy-MC 2026-04-21 17:10:20 +08:00
CN-JS-HuiBai
faeeb9bc3c 修改README
All checks were successful
Auto Build / Build binaries (push) Successful in 3m39s
2026-04-16 21:57:35 +08:00
CN-JS-HuiBai
d6ab7ddfad 添加解压功能 2026-04-16 21:55:49 +08:00
CN-JS-HuiBai
5a06cf400b 添加存储桶复制脚本 2026-04-16 21:52:54 +08:00
21 changed files with 1668 additions and 102 deletions

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ CLAUDE.md
AGENTS.md AGENTS.md
/.claude/ /.claude/
/.codex-*/ /.codex-*/
logs

View File

@@ -79,7 +79,6 @@ curl -fsSL https://s3.cloudyun.top/downloads/singbox/install.sh | bash
``` ```
`install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。 `install.sh` 默认会从 `https://s3.cloudyun.top/downloads/singbox` 下载对应架构的预编译 `sing-box` 二进制,再继续进入面板和服务配置流程。
该脚本同时具有更新的功能 该脚本同时具有更新的功能
```
`update.sh` 会从同一发布地址下载对应架构的 `sing-box` 二进制,并自动重启已检测到的 `singbox``sing-box` 服务。 `update.sh` 会从同一发布地址下载对应架构的 `sing-box` 二进制,并自动重启已检测到的 `singbox``sing-box` 服务。

126
client-install.sh Normal file
View File

@@ -0,0 +1,126 @@
#!/bin/bash
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
set -e
CONFIG_DIR="/etc/sing-box-client"
BINARY_PATH="/usr/local/bin/sing-box-client"
SERVICE_NAME="sbproxy-client"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
echo -e "${GREEN}SBProxy Client 快速安装脚本${NC}"
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}此脚本必须以 root 身份运行${NC}"
exit 1
fi
# 1. 询问远程服务端参数
read -p "请输入远程 SBProxy 服务器地址: " REMOTE_ADDR
read -p "请输入远程 SBProxy 服务器端口: " REMOTE_PORT
read -p "请输入连接密码 (UUID): " REMOTE_PASS
# 2. 询问本地代理参数
read -p "请输入本地 SOCKS5 监听端口 [1080]: " LOCAL_S5_PORT
LOCAL_S5_PORT=${LOCAL_S5_PORT:-1080}
# 3. 下载 Sing-box (检测架构)
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) BINARY_ARCH="amd64" ;;
aarch64|arm64) BINARY_ARCH="arm64" ;;
armv7l|armv7) BINARY_ARCH="armv7" ;;
*)
echo -e "${RED}不支持的架构: $ARCH${NC}"
exit 1
;;
esac
echo -e "${YELLOW}正在下载 Sing-box...${NC}"
# 使用项目默认的发布地址
RELEASE_BASE_URL="${RELEASE_BASE_URL:-https://s3.cloudyun.top/downloads/singbox}"
DOWNLOAD_URL="${RELEASE_BASE_URL}/sing-box-linux-${BINARY_ARCH}"
if command -v curl >/dev/null 2>&1; then
curl -fL "${DOWNLOAD_URL}" -o "${BINARY_PATH}"
elif command -v wget >/dev/null 2>&1; then
wget -O "${BINARY_PATH}" "${DOWNLOAD_URL}"
else
echo -e "${RED}未安装 curl 或 wget${NC}"
exit 1
fi
chmod +x "${BINARY_PATH}"
# 4. 生成配置文件
mkdir -p "${CONFIG_DIR}"
cat > "${CONFIG_DIR}/config.json" <<EOF
{
"log": {
"level": "info",
"timestamp": true
},
"inbounds": [
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": $LOCAL_S5_PORT
}
],
"outbounds": [
{
"type": "sbproxy",
"tag": "mc-out",
"server": "$REMOTE_ADDR",
"server_port": $REMOTE_PORT,
"password": "$REMOTE_PASS"
},
{
"type": "direct",
"tag": "direct"
}
],
"route": {
"rules": [
{
"inbound": ["socks-in"],
"action": "sniff"
},
{
"inbound": ["socks-in"],
"outbound": "mc-out"
}
]
}
}
EOF
# 5. 创建 Systemd 服务
cat > "${SERVICE_FILE}" <<EOF
[Unit]
Description=SBProxy Client Service
After=network.target
[Service]
ExecStart=${BINARY_PATH} run -c ${CONFIG_DIR}/config.json
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}"
systemctl restart "${SERVICE_NAME}"
echo -e "${GREEN}客户端安装完成!${NC}"
echo -e "${YELLOW}本地 SOCKS5 代理地址: 127.0.0.1:${LOCAL_S5_PORT}${NC}"
echo -e "配置文件位于: ${CONFIG_DIR}/config.json"
echo -e "你可以通过 systemctl status ${SERVICE_NAME} 查看状态。"

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"net" "net"
@@ -11,6 +12,7 @@ import (
C "github.com/sagernet/sing-box/constant" C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common/bufio" "github.com/sagernet/sing/common/bufio"
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
M "github.com/sagernet/sing/common/metadata" M "github.com/sagernet/sing/common/metadata"
@@ -89,6 +91,11 @@ func fetch(args []string) error {
if err != nil { if err != nil {
return err return err
} }
case "sbproxy":
err = fetchSBProxy(parsedURL)
if err != nil {
return err
}
default: default:
return E.New("unsupported scheme: ", parsedURL.Scheme) return E.New("unsupported scheme: ", parsedURL.Scheme)
} }
@@ -113,3 +120,30 @@ func fetchHTTP(httpClient *http.Client, parsedURL *url.URL) error {
} }
return err return err
} }
func fetchSBProxy(parsedURL *url.URL) error {
var password string
if parsedURL.User != nil {
password, _ = parsedURL.User.Password()
}
username := parsedURL.Query().Get("username")
if username == "" && parsedURL.User != nil {
username = parsedURL.User.Username()
}
outboundOptions := option.Outbound{
Type: C.TypeSBProxy,
Tag: "proxy",
Options: &option.SBProxyOutboundOptions{
ServerOptions: option.ServerOptions{
Server: parsedURL.Hostname(),
ServerPort: uint16(M.ParseSocksaddr(parsedURL.Host).Port),
},
Username: username,
Password: password,
},
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(outboundOptions)
}

View File

@@ -32,6 +32,7 @@ const (
TypeOCM = "ocm" TypeOCM = "ocm"
TypeOOMKiller = "oom-killer" TypeOOMKiller = "oom-killer"
TypeXBoard = "xboard" TypeXBoard = "xboard"
TypeSBProxy = "sbproxy"
) )
const ( const (
@@ -93,6 +94,8 @@ func ProxyDisplayName(proxyType string) string {
return "Selector" return "Selector"
case TypeURLTest: case TypeURLTest:
return "URLTest" return "URLTest"
case TypeSBProxy:
return "SBProxy"
default: default:
return "Unknown" return "Unknown"
} }

View File

@@ -163,6 +163,11 @@ func (r *httpRequest) SetURL(link string) (err error) {
if err != nil { if err != nil {
return return
} }
switch r.request.URL.Scheme {
case "http", "https":
default:
return E.New("unsupported protocol scheme for HTTP request: ", r.request.URL.Scheme)
}
if r.request.URL.User != nil { if r.request.URL.User != nil {
user := r.request.URL.User.Username() user := r.request.URL.User.Username()
password, _ := r.request.URL.User.Password() password, _ := r.request.URL.User.Password()

3
go.mod
View File

@@ -18,6 +18,7 @@ require (
github.com/libdns/acmedns v0.5.0 github.com/libdns/acmedns v0.5.0
github.com/libdns/alidns v1.0.6 github.com/libdns/alidns v1.0.6
github.com/libdns/cloudflare v0.2.2 github.com/libdns/cloudflare v0.2.2
github.com/libdns/libdns v1.1.1
github.com/libdns/tencentcloud v1.4.3 github.com/libdns/tencentcloud v1.4.3
github.com/logrusorgru/aurora v2.0.3+incompatible github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/metacubex/utls v1.8.4 github.com/metacubex/utls v1.8.4
@@ -46,6 +47,7 @@ require (
github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7 github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.7
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854
github.com/sandertv/go-raknet v1.15.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/vishvananda/netns v0.0.5 github.com/vishvananda/netns v0.0.5
@@ -96,7 +98,6 @@ require (
github.com/jsimonetti/rtnetlink v1.4.0 // indirect github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/mdlayher/netlink v1.9.0 // indirect github.com/mdlayher/netlink v1.9.0 // indirect
github.com/mdlayher/socket v0.5.1 // indirect github.com/mdlayher/socket v0.5.1 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect

2
go.sum
View File

@@ -262,6 +262,8 @@ github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c h1:
github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0= github.com/sagernet/wireguard-go v0.0.2-beta.1.0.20260224074747-506b7631853c/go.mod h1:WUxgxUDZoCF2sxVmW+STSxatP02Qn3FcafTiI2BLtE0=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 h1:6uUiZcDRnZSAegryaUGwPC/Fj13JSHwiTftrXhMmYOc=
github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA= github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854/go.mod h1:LtfoSK3+NG57tvnVEHgcuBW9ujgE8enPSgzgwStwCAA=
github.com/sandertv/go-raknet v1.15.0 h1:OCJFvVsn5RWXOXF+gZD5uTAmy9RbOofUzOhvSyuR1tE=
github.com/sandertv/go-raknet v1.15.0/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=

View File

@@ -32,6 +32,7 @@ import (
"github.com/sagernet/sing-box/protocol/tor" "github.com/sagernet/sing-box/protocol/tor"
"github.com/sagernet/sing-box/protocol/trojan" "github.com/sagernet/sing-box/protocol/trojan"
"github.com/sagernet/sing-box/protocol/tun" "github.com/sagernet/sing-box/protocol/tun"
"github.com/sagernet/sing-box/protocol/sbproxy"
"github.com/sagernet/sing-box/protocol/vless" "github.com/sagernet/sing-box/protocol/vless"
"github.com/sagernet/sing-box/protocol/vmess" "github.com/sagernet/sing-box/protocol/vmess"
"github.com/sagernet/sing-box/service/resolved" "github.com/sagernet/sing-box/service/resolved"
@@ -63,6 +64,7 @@ func InboundRegistry() *inbound.Registry {
shadowtls.RegisterInbound(registry) shadowtls.RegisterInbound(registry)
vless.RegisterInbound(registry) vless.RegisterInbound(registry)
anytls.RegisterInbound(registry) anytls.RegisterInbound(registry)
sbproxy.RegisterInbound(registry)
registerQUICInbounds(registry) registerQUICInbounds(registry)
registerStubForRemovedInbounds(registry) registerStubForRemovedInbounds(registry)
@@ -91,6 +93,7 @@ func OutboundRegistry() *outbound.Registry {
shadowtls.RegisterOutbound(registry) shadowtls.RegisterOutbound(registry)
vless.RegisterOutbound(registry) vless.RegisterOutbound(registry)
anytls.RegisterOutbound(registry) anytls.RegisterOutbound(registry)
sbproxy.RegisterOutbound(registry)
registerQUICOutbounds(registry) registerQUICOutbounds(registry)
registerStubForRemovedOutbounds(registry) registerStubForRemovedOutbounds(registry)

View File

@@ -31,6 +31,9 @@ EXISTING_INSTALL=0
EXISTING_CONFIG_SOURCE="" EXISTING_CONFIG_SOURCE=""
PROMPT_FOR_CONFIG=1 PROMPT_FOR_CONFIG=1
CONFIG_BACKUP_DIR="" CONFIG_BACKUP_DIR=""
DEPLOY_MODE="panel"
SBPROXY_MODE="" # server or client
SB_LOCAL_SOCKS_PORT=""
echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}" echo -e "${GREEN}Welcome to singbox Release Installation Script${NC}"
echo -e "${GREEN}Script version: ${SCRIPT_VERSION}${NC}" echo -e "${GREEN}Script version: ${SCRIPT_VERSION}${NC}"
@@ -177,8 +180,11 @@ import json
import sys import sys
path = sys.argv[1] path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except Exception:
data = {}
xboard = {} xboard = {}
for service in data.get("services") or []: for service in data.get("services") or []:
@@ -341,8 +347,11 @@ import json
import sys import sys
source_path, route_path, outbounds_path = sys.argv[1:4] source_path, route_path, outbounds_path = sys.argv[1:4]
try:
with open(source_path, "r", encoding="utf-8") as f: with open(source_path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except Exception:
data = {}
route_written = 0 route_written = 0
if isinstance(data.get("route"), dict): if isinstance(data.get("route"), dict):
@@ -495,8 +504,11 @@ import json
import sys import sys
path = sys.argv[1] path = sys.argv[1]
try:
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except Exception:
data = {}
nodes = data.get("Nodes") or [] nodes = data.get("Nodes") or []
node = nodes[0] if nodes else {} node = nodes[0] if nodes else {}
@@ -695,6 +707,16 @@ download_binary
cleanup_legacy_service cleanup_legacy_service
if [[ "$PROMPT_FOR_CONFIG" -eq 1 ]]; then if [[ "$PROMPT_FOR_CONFIG" -eq 1 ]]; then
echo -e "${YELLOW}Select deployment mode:${NC}"
echo -e "1) Panel Mode (Xboard/UniProxy)"
echo -e "2) Standalone Mode (SBProxy Node)"
read -u 3 -p "Select [1]: " DEPLOY_MODE_INDEX
case "${DEPLOY_MODE_INDEX:-1}" in
2) DEPLOY_MODE="standalone" ;;
*) DEPLOY_MODE="panel" ;;
esac
if [[ "$DEPLOY_MODE" == "panel" ]]; then
read -u 3 -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL read -u 3 -p "Enter Panel URL [${PANEL_URL}]: " INPUT_URL
PANEL_URL="$(sanitize_value "${INPUT_URL:-$PANEL_URL}")" PANEL_URL="$(sanitize_value "${INPUT_URL:-$PANEL_URL}")"
@@ -750,6 +772,37 @@ if [[ "$PROMPT_FOR_CONFIG" -eq 1 ]]; then
done done
((i++)) ((i++))
done done
else
echo -e "${YELLOW}Standalone mode selected.${NC}"
echo -e "Which SBProxy configuration to generate?"
echo -e "1) Inbound (Run a Minecraft proxy server)"
echo -e "2) Outbound (Connect to a Minecraft proxy server)"
read -u 3 -p "Select [1]: " SBPROXY_MODE_INDEX
case "${SBPROXY_MODE_INDEX:-1}" in
2) SBPROXY_MODE="client" ;;
*) SBPROXY_MODE="server" ;;
esac
if [[ "$SBPROXY_MODE" == "server" ]]; then
read -u 3 -p "Enter Listen Port [25565]: " SB_LISTEN_PORT
SB_LISTEN_PORT="${SB_LISTEN_PORT:-25565}"
read -u 3 -p "Enter Minecraft Backend (Dest) [127.0.0.1:25566]: " SB_DEST
SB_DEST="${SB_DEST:-127.0.0.1:25566}"
read -u 3 -p "Enter MOTD [§bStandalone §6SBProxy§r]: " SB_MOTD
SB_MOTD="${SB_MOTD:-§bStandalone §6SBProxy§r}"
read -u 3 -p "Enter Minecraft Version [1.20.1]: " SB_VERSION
SB_VERSION="${SB_VERSION:-1.20.1}"
read -u 3 -p "Enter Max Players [1000]: " SB_MAX_PLAYERS
SB_MAX_PLAYERS="${SB_MAX_PLAYERS:-1000}"
else
read -u 3 -p "Enter SBProxy Server Address: " SB_OUT_ADDR
read -u 3 -p "Enter SBProxy Server Port: " SB_OUT_PORT
read -u 3 -p "Enter Local SOCKS5 Listen Port [1080]: " SB_LOCAL_SOCKS_PORT
SB_LOCAL_SOCKS_PORT="${SB_LOCAL_SOCKS_PORT:-1080}"
read -u 3 -p "Enter Username (Optional): " SB_OUT_USER
read -u 3 -p "Enter Password: " SB_OUT_PASS
fi
fi
DNS_MODE_DEFAULT=${DNS_MODE:-udp} DNS_MODE_DEFAULT=${DNS_MODE:-udp}
read -u 3 -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE read -u 3 -p "Enter DNS mode [${DNS_MODE_DEFAULT}] (udp/local): " INPUT_DNS_MODE
@@ -817,8 +870,9 @@ esac
echo -e "${YELLOW}Syncing system time...${NC}" echo -e "${YELLOW}Syncing system time...${NC}"
timedatectl set-ntp true || true timedatectl set-ntp true || true
if [[ "$DEPLOY_MODE" == "panel" ]]; then
if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then if [[ -z "$PANEL_URL" || -z "$PANEL_TOKEN" ]]; then
echo -e "${RED}All fields are required!${NC}" echo -e "${RED}Panel URL and Token are required!${NC}"
exit 1 exit 1
fi fi
@@ -858,6 +912,10 @@ EOF
SERVICE_JSON+=$'\n ]' SERVICE_JSON+=$'\n ]'
fi fi
SERVICE_JSON+=$'\n }' SERVICE_JSON+=$'\n }'
else
# Standalone mode doesn't use the xboard service
SERVICE_JSON=""
fi
echo -e "${YELLOW}Generating configuration...${NC}" echo -e "${YELLOW}Generating configuration...${NC}"
backup_path_if_exists "$CONFIG_BASE_FILE" backup_path_if_exists "$CONFIG_BASE_FILE"
@@ -885,15 +943,81 @@ ${DNS_SERVER_JSON}
] ]
}, },
"services": [ "services": [
${SERVICE_JSON} $(echo "$SERVICE_JSON" | sed '/^$/d')
], ],
"inbounds": [] "inbounds": [
$(if [[ "$DEPLOY_MODE" == "standalone" && "$SBPROXY_MODE" == "server" ]]; then
cat <<INNEREOF
{
"type": "sbproxy",
"tag": "mc-in",
"listen": "::",
"listen_port": $SB_LISTEN_PORT,
"motd": "$SB_MOTD",
"version": "$SB_VERSION",
"max_players": $SB_MAX_PLAYERS,
"dest": "$SB_DEST"
}
INNEREOF
elif [[ "$DEPLOY_MODE" == "standalone" && "$SBPROXY_MODE" == "client" ]]; then
cat <<INNEREOF
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": ${SB_LOCAL_SOCKS_PORT:-1080}
}
INNEREOF
fi)
]
} }
EOF EOF
normalize_outbound_config_layout normalize_outbound_config_layout
if [[ -f "$CONFIG_DIR/config.json" ]]; then if [[ "$DEPLOY_MODE" == "standalone" && "$SBPROXY_MODE" == "client" ]]; then
echo -e "${YELLOW}Generating SBProxy outbound config...${NC}"
cat > "$CONFIG_OUTBOUNDS_FILE" <<EOF
{
"outbounds": [
{
"type": "sbproxy",
"tag": "mc-out",
"server": "$SB_OUT_ADDR",
"server_port": $SB_OUT_PORT,
"username": "$SB_OUT_USER",
"password": "$SB_OUT_PASS"
},
{
"type": "direct",
"tag": "direct"
}
]
}
EOF
echo -e "${YELLOW}Generating SBProxy client route config...${NC}"
cat > "$CONFIG_ROUTE_FILE" <<EOF
{
"route": {
"rules": [
{
"protocol": "dns",
"action": "hijack-dns"
},
{
"inbound": ["socks-in"],
"action": "sniff"
},
{
"inbound": ["socks-in"],
"outbound": "mc-out"
}
],
"auto_detect_interface": true
}
}
EOF
elif [[ -f "$CONFIG_DIR/config.json" ]]; then
rm -f "$CONFIG_ROUTE_FILE" "$CONFIG_OUTBOUNDS_FILE" rm -f "$CONFIG_ROUTE_FILE" "$CONFIG_OUTBOUNDS_FILE"
if ! extract_legacy_config_sections "$CONFIG_DIR/config.json"; then if ! extract_legacy_config_sections "$CONFIG_DIR/config.json"; then
echo -e "${YELLOW}Legacy config detected but route/outbounds could not be extracted automatically. Writing default route/outbound files.${NC}" echo -e "${YELLOW}Legacy config detected but route/outbounds could not be extracted automatically. Writing default route/outbound files.${NC}"

24
option/sbproxy.go Normal file
View File

@@ -0,0 +1,24 @@
package option
type SBProxyInboundOptions struct {
ListenOptions
Users []SBProxyUser `json:"users,omitempty"`
// Masquerade Config (Unified MC)
MOTD string `json:"motd,omitempty"`
MaxPlayers int `json:"max_players,omitempty"`
Version string `json:"version,omitempty"`
Dest string `json:"dest,omitempty"`
}
type SBProxyOutboundOptions struct {
DialerOptions
ServerOptions
Username string `json:"username,omitempty"` // Default to "user" if not set
Password string `json:"password,omitempty"`
}
type SBProxyUser struct {
Name string `json:"name,omitempty"`
Password string `json:"password,omitempty"`
}

View File

@@ -0,0 +1,74 @@
package sbproxy
import (
"context"
"fmt"
"net"
"net/netip"
"time"
"github.com/sandertv/go-raknet"
"github.com/sagernet/sing-box/adapter"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func (h *Inbound) handleBedrockPacket(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
}
func (h *Inbound) startBedrockListener() {
l, err := raknet.Listen(h.options.ListenOptions.Listen.Build(netip.Addr{}).String())
if err != nil {
h.logger.Error("failed to start Bedrock listener: ", err)
return
}
defer l.Close()
// Setup RakNet Pong Data (Active scanning response)
motd := h.options.MOTD
if motd == "" {
motd = "A Minecraft Server"
}
ver := h.options.Version
if ver == "" {
ver = "1.20.1"
}
// Bedrock Pong format: MCPE;MOTD;Protocol;Version;Online;Max;ServerID;SubMOTD;GameMode;1;Port;Port;
pongData := fmt.Sprintf("MCPE;%s;594;%s;0;%d;%d;SBProxy;Creative;1;19132;19132;",
motd, ver, h.options.MaxPlayers, time.Now().UnixNano())
l.PongData([]byte(pongData))
h.logger.Info("Bedrock listener (UDP) started for SBProxy")
for {
conn, err := l.Accept()
if err != nil {
break
}
go h.handleBedrockConnection(conn)
}
}
func (h *Inbound) handleBedrockConnection(conn net.Conn) {
defer conn.Close()
// 1. Bedrock Handshake / Login (Simplified)
buf := make([]byte, 1024)
_, err := conn.Read(buf)
if err != nil {
return
}
packetID := buf[0]
if packetID != 0x01 { // Login
return
}
metadata := adapter.InboundContext{
Inbound: h.Tag(),
InboundType: h.Type(),
Source: M.SocksaddrFromNet(conn.RemoteAddr()).Unwrap(),
}
h.handlePlay(context.Background(), conn, "bedrock_user", metadata, nil)
}

View File

@@ -0,0 +1,59 @@
package sbproxy
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
)
type cfb8 struct {
block cipher.Block
iv []byte
tmp []byte
decrypt bool
}
func NewCFB8(block cipher.Block, iv []byte, decrypt bool) cipher.Stream {
bs := block.BlockSize()
if len(iv) != bs {
panic("cfb8: IV length must equal block size")
}
return &cfb8{
block: block,
iv: append([]byte(nil), iv...),
tmp: make([]byte, bs),
decrypt: decrypt,
}
}
func (x *cfb8) XORKeyStream(dst, src []byte) {
for i := range src {
x.block.Encrypt(x.tmp, x.iv)
val := src[i]
dst[i] = val ^ x.tmp[0]
if x.decrypt {
copy(x.iv, x.iv[1:])
x.iv[len(x.iv)-1] = val
} else {
copy(x.iv, x.iv[1:])
x.iv[len(x.iv)-1] = dst[i]
}
}
}
// NewCipherStream now takes a session salt (IV) to prevent IV reuse attacks.
func NewCipherStream(password string, salt []byte, decrypt bool) (cipher.Stream, error) {
// Derive session key and IV from password and per-session salt
h := sha256.New()
h.Write([]byte(password))
h.Write(salt)
sessionMaterial := h.Sum(nil)
block, err := aes.NewCipher(sessionMaterial)
if err != nil {
return nil, err
}
// Use the first 16 bytes of the derived material as IV
return NewCFB8(block, sessionMaterial[:16], decrypt), nil
}

110
protocol/sbproxy/inbound.go Normal file
View File

@@ -0,0 +1,110 @@
package sbproxy
import (
"context"
"net"
"sync"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/inbound"
"github.com/sagernet/sing-box/common/listener"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
"github.com/sagernet/sing/common/buf"
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func RegisterInbound(registry *inbound.Registry) {
inbound.Register[option.SBProxyInboundOptions](registry, C.TypeSBProxy, NewInbound)
}
type Inbound struct {
inbound.Adapter
ctx context.Context
router adapter.Router
logger logger.ContextLogger
options option.SBProxyInboundOptions
listener *listener.Listener
users []option.SBProxyUser
ssmMutex sync.RWMutex
tracker adapter.SSMTracker
}
func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SBProxyInboundOptions) (adapter.Inbound, error) {
inbound := &Inbound{
Adapter: inbound.NewAdapter(C.TypeSBProxy, tag),
ctx: ctx,
router: router,
logger: logger,
options: options,
users: options.Users,
}
// SBProxy always supports both TCP (Java MC) and UDP (Bedrock MC)
inbound.listener = listener.New(listener.Options{
Context: ctx,
Logger: logger,
Network: []string{N.NetworkTCP}, // Handle Java MC (TCP) via standard listener
Listen: options.ListenOptions,
ConnectionHandler: inbound,
})
return inbound, nil
}
func (h *Inbound) Type() string { return C.TypeSBProxy }
func (h *Inbound) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateStart {
return nil
}
// Start Java MC (TCP) Listener
err := h.listener.Start()
if err != nil {
return err
}
// Always start Bedrock MC (UDP) Listener on the same port (UDP)
go h.startBedrockListener()
return nil
}
func (h *Inbound) Close() error {
return common.Close(h.listener)
// RakNet listener in bedrock_server.go should be closed too if we keep a reference
}
func (h *Inbound) SetTracker(tracker adapter.SSMTracker) {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
h.tracker = tracker
}
func (h *Inbound) UpdateUsers(users, keys, flows []string) error {
h.ssmMutex.Lock()
defer h.ssmMutex.Unlock()
newUsers := make([]option.SBProxyUser, len(users))
for i := range users {
newUsers[i] = option.SBProxyUser{Name: users[i], Password: keys[i]}
}
h.users = newUsers
return nil
}
func (h *Inbound) NewConnectionEx(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
// TCP traffic always handled as Java MC
h.handleJavaConnection(ctx, conn, metadata, onClose)
}
func (h *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
// UDP traffic from standard listener (if any)
}
func (h *Inbound) NewPacketEx(buffer *buf.Buffer, source M.Socksaddr) {
}

View File

@@ -0,0 +1,121 @@
package sbproxy
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net"
"github.com/sagernet/sing-box/adapter"
N "github.com/sagernet/sing/common/network"
)
func (h *Inbound) handleJavaConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
_, _, err := ReadVarInt(conn) // packet len
if err != nil {
conn.Close()
return
}
packetID, _, err := ReadVarInt(conn)
if err != nil || packetID != 0x00 {
conn.Close()
return
}
ReadVarInt(conn) // protocol version
ReadString(conn) // server address
binary.Read(conn, binary.BigEndian, new(uint16))
nextState, _, _ := ReadVarInt(conn)
switch nextState {
case 1: // Status
h.handleJavaStatus(conn)
case 2: // Login
h.handleJavaLogin(ctx, conn, metadata, onClose)
default:
conn.Close()
}
}
func (h *Inbound) handleJavaStatus(conn net.Conn) {
for {
packetLen, _, err := ReadVarInt(conn)
if err != nil {
return
}
packetID, _, err := ReadVarInt(conn)
if err != nil {
return
}
if packetID == 0x00 { // Status Request
motd := h.options.MOTD
if motd == "" {
motd = "A Minecraft Server"
}
ver := h.options.Version
if ver == "" {
ver = "1.20.1"
}
resp := map[string]any{
"version": map[string]any{"name": ver, "protocol": 763},
"players": map[string]any{"max": h.options.MaxPlayers, "online": 0, "sample": []any{}},
"description": map[string]any{"text": motd},
}
data, _ := json.Marshal(resp)
var body bytes.Buffer
WriteVarInt(&body, 0x00) // Response ID
WriteString(&body, string(data))
WriteVarInt(conn, int32(body.Len()))
conn.Write(body.Bytes())
} else if packetID == 0x01 { // Ping
var b [8]byte
if _, err := io.ReadFull(conn, b[:]); err != nil {
return
}
var body bytes.Buffer
WriteVarInt(&body, 0x01)
body.Write(b[:])
WriteVarInt(conn, int32(body.Len()))
conn.Write(body.Bytes())
return
} else {
io.CopyN(io.Discard, conn, int64(packetLen)-1)
}
}
}
func (h *Inbound) handleJavaLogin(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
// Login Start
_, _, err := ReadVarInt(conn)
if err != nil {
conn.Close()
return
}
packetID, _, err := ReadVarInt(conn)
if err != nil || packetID != 0x00 {
conn.Close()
return
}
user, _ := ReadString(conn)
// Offline Mode: No encryption/auth sequence, just Login Success
var loginSuccess bytes.Buffer
WriteVarInt(&loginSuccess, 0x02) // Login Success ID
// UUID (offline)
loginSuccess.Write(make([]byte, 16))
WriteString(&loginSuccess, user)
WriteVarInt(&loginSuccess, 0) // No properties
WriteVarInt(conn, int32(loginSuccess.Len()))
conn.Write(loginSuccess.Bytes())
h.handlePlay(ctx, conn, user, metadata, onClose)
}

View File

@@ -0,0 +1,362 @@
package sbproxy
import (
"bytes"
"context"
"crypto/cipher"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/common/dialer"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/log"
"github.com/sagernet/sing-box/option"
"github.com/sandertv/go-raknet"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
)
func RegisterOutbound(registry *outbound.Registry) {
outbound.Register[option.SBProxyOutboundOptions](registry, C.TypeSBProxy, NewOutbound)
}
type Outbound struct {
outbound.Adapter
ctx context.Context
logger log.ContextLogger
options option.SBProxyOutboundOptions
dialer N.Dialer
serverAddr M.Socksaddr
}
func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SBProxyOutboundOptions) (adapter.Outbound, error) {
if strings.TrimSpace(options.Password) == "" {
return nil, errors.New("sbproxy outbound password is required")
}
outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
if err != nil {
return nil, err
}
return &Outbound{
Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSBProxy, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
ctx: ctx,
logger: logger,
options: options,
dialer: outboundDialer,
serverAddr: options.ServerOptions.Build(),
}, nil
}
func (h *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
conn, err := h.dialer.DialContext(ctx, N.NetworkTCP, h.serverAddr)
if err != nil {
return nil, err
}
// 1. Java MC Handshake (State 2: Login)
var handshake bytes.Buffer
WriteVarInt(&handshake, 763) // Version
WriteString(&handshake, h.serverAddr.AddrString())
binary.Write(&handshake, binary.BigEndian, uint16(h.serverAddr.Port))
WriteVarInt(&handshake, 2)
h.sendPacket(conn, 0x00, handshake.Bytes())
// 2. Login Start
var loginStart bytes.Buffer
username := h.options.Username
if username == "" { username = "user" }
WriteString(&loginStart, username)
h.sendPacket(conn, 0x00, loginStart.Bytes())
// 3. Consume LoginSuccess packet.
// We must consume the full packet body; reading only packet length and ID
// leaves unread bytes on the stream and breaks subsequent challenge parsing.
loginPacketLen, _, err := ReadVarInt(conn)
if err != nil {
_ = conn.Close()
return nil, err
}
if loginPacketLen <= 0 {
_ = conn.Close()
return nil, io.ErrUnexpectedEOF
}
loginPacket := make([]byte, int(loginPacketLen))
if _, err = io.ReadFull(conn, loginPacket); err != nil {
_ = conn.Close()
return nil, err
}
loginReader := bytes.NewReader(loginPacket)
loginPacketID, _, err := ReadVarInt(loginReader)
if err != nil {
_ = conn.Close()
return nil, err
}
if loginPacketID != 0x02 { // Login Success
_ = conn.Close()
return nil, io.ErrUnexpectedEOF
}
// 4. Connect with SBProxy logic
playConn, err := h.handleClientPlay(ctx, conn, false)
if err != nil {
_ = conn.Close()
return nil, err
}
if cpc, ok := playConn.(*clientPlayConn); ok {
if err := cpc.sendDestination(destination); err != nil {
_ = conn.Close()
return nil, err
}
}
return playConn, nil
}
func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
conn, err := raknet.Dial(h.serverAddr.String())
if err != nil {
return nil, err
}
// 1. Bedrock Login (Manual 0x01)
if _, err = conn.Write([]byte{0x01}); err != nil {
_ = conn.Close()
return nil, err
}
// 2. Wrap as play
return h.handleClientPlayPacket(ctx, conn)
}
func (h *Outbound) handleClientPlay(ctx context.Context, conn net.Conn, isUDP bool) (net.Conn, error) {
// 1. Receive Encryption Challenge from Server
payload, err := h.readClientPacket(conn, isUDP, JavaChannelEncryption)
if err != nil {
return nil, err
}
if len(payload) < 24 {
return nil, io.ErrUnexpectedEOF
}
_ = int64(binary.BigEndian.Uint64(payload[:8])) // serverTime
serverNonce := payload[8:24]
// 2. Respond with Client Nonce and Time
clientNonce := make([]byte, 16)
rand.Read(clientNonce)
now := time.Now().Unix()
var response bytes.Buffer
binary.Write(&response, binary.BigEndian, now)
response.Write(clientNonce)
err = h.sendClientPacket(conn, isUDP, JavaChannelEncryption, response.Bytes())
if err != nil {
return nil, err
}
// 3. Setup Encryption
sessionSalt := make([]byte, 16)
for i := 0; i < 16; i++ {
sessionSalt[i] = serverNonce[i] ^ clientNonce[i]
}
encrypter, err := NewCipherStream(h.options.Password, sessionSalt, false)
if err != nil {
return nil, err
}
decrypter, err := NewCipherStream(h.options.Password, sessionSalt, true)
if err != nil {
return nil, err
}
return &clientPlayConn{
Conn: conn,
encrypter: encrypter,
decrypter: decrypter,
isUDP: isUDP,
outbound: h,
}, nil
}
func (h *Outbound) sendPacket(w io.Writer, id int32, data []byte) error {
var body bytes.Buffer
WriteVarInt(&body, id)
body.Write(data)
WriteVarInt(w, int32(body.Len()))
_, err := w.Write(body.Bytes())
return err
}
func (h *Outbound) handleClientPlayPacket(ctx context.Context, conn net.Conn) (net.PacketConn, error) {
c, err := h.handleClientPlay(ctx, conn, true)
if err != nil {
return nil, err
}
return &clientPlayPacketConn{
clientPlayConn: c.(*clientPlayConn),
}, nil
}
func (h *Outbound) sendClientPacket(w io.Writer, isUDP bool, channel string, data []byte) error {
if isUDP {
_, err := w.Write(append([]byte{BedrockPacketTunnel}, data...))
return err
}
var body bytes.Buffer
WriteString(&body, channel)
body.Write(data)
var packet bytes.Buffer
WriteVarInt(&packet, 0x0D) // Java Plugin Message (Play state usually 0x0D or 0x0C depending on ver, here matching server 0x0D)
packet.Write(body.Bytes())
WriteVarInt(w, int32(packet.Len()))
_, err := w.Write(packet.Bytes())
return err
}
func (h *Outbound) readClientPacket(r io.Reader, isUDP bool, channel string) ([]byte, error) {
if isUDP {
buf := make([]byte, 2048)
n, err := r.Read(buf)
if err != nil {
return nil, err
}
if n == 0 || buf[0] != BedrockPacketTunnel {
return nil, io.ErrUnexpectedEOF
}
return buf[1:n], nil
}
pLen, _, err := ReadVarInt(r)
if err != nil {
return nil, err
}
if pLen <= 0 {
return nil, io.ErrUnexpectedEOF
}
packet := make([]byte, int(pLen))
if _, err = io.ReadFull(r, packet); err != nil {
return nil, err
}
reader := bytes.NewReader(packet)
pID, _, err := ReadVarInt(reader)
if err != nil {
return nil, err
}
if pID != 0x18 { // Java Plugin Message (Server -> Client in Play is 0x18)
return nil, fmt.Errorf("sbproxy: unexpected packet id before challenge, got=0x%x expected=0x18", pID)
}
ch, err := ReadString(reader)
if err != nil {
return nil, err
}
if ch != channel {
return nil, fmt.Errorf("sbproxy: unexpected channel before challenge, got=%q expected=%q", ch, channel)
}
remaining := reader.Len()
if remaining < 0 {
return nil, errors.New("invalid client packet payload length")
}
payload := make([]byte, remaining)
if _, err = io.ReadFull(reader, payload); err != nil {
return nil, err
}
return payload, nil
}
type clientPlayConn struct {
net.Conn
encrypter cipher.Stream
decrypter cipher.Stream
isUDP bool
outbound *Outbound
readRemain []byte
}
func destinationString(destination M.Socksaddr) (string, error) {
host := strings.TrimSpace(destination.AddrString())
if host == "" {
return "", errors.New("sbproxy: destination host is empty")
}
if destination.Port == 0 {
return "", errors.New("sbproxy: destination port is empty")
}
return net.JoinHostPort(host, fmt.Sprintf("%d", destination.Port)), nil
}
func (c *clientPlayConn) sendDestination(destination M.Socksaddr) error {
destinationText, err := destinationString(destination)
if err != nil {
return err
}
payload := []byte(destinationText)
encrypted := make([]byte, len(payload))
c.encrypter.XORKeyStream(encrypted, payload)
return c.outbound.sendClientPacket(c.Conn, c.isUDP, JavaChannelData, encrypted)
}
func (c *clientPlayConn) Read(b []byte) (int, error) {
// Return buffered data from previous oversized read first
if len(c.readRemain) > 0 {
n := copy(b, c.readRemain)
c.readRemain = c.readRemain[n:]
if len(c.readRemain) == 0 {
c.readRemain = nil
}
return n, nil
}
for {
var payload []byte
var err error
if c.isUDP {
payload, err = c.outbound.readClientPacket(c.Conn, true, "")
} else {
payload, err = c.outbound.readClientPacket(c.Conn, false, JavaChannelData)
}
if err != nil {
return 0, err
}
if len(payload) == 0 {
continue // Retry instead of returning (0, nil) which violates io.Reader
}
c.decrypter.XORKeyStream(payload, payload)
n := copy(b, payload)
if n < len(payload) {
// Buffer the remaining data for the next Read call
c.readRemain = make([]byte, len(payload)-n)
copy(c.readRemain, payload[n:])
}
return n, nil
}
}
func (c *clientPlayConn) Write(b []byte) (int, error) {
encrypted := make([]byte, len(b))
c.encrypter.XORKeyStream(encrypted, b)
err := c.outbound.sendClientPacket(c.Conn, c.isUDP, JavaChannelData, encrypted)
if err != nil {
return 0, err
}
return len(b), nil
}
type clientPlayPacketConn struct {
*clientPlayConn
}
func (c *clientPlayPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
n, err := c.clientPlayConn.Read(b)
return n, c.clientPlayConn.RemoteAddr(), err
}
func (c *clientPlayPacketConn) WriteTo(b []byte, addr net.Addr) (int, error) {
return c.clientPlayConn.Write(b)
}

282
protocol/sbproxy/play.go Normal file
View File

@@ -0,0 +1,282 @@
package sbproxy
import (
"bytes"
"context"
"crypto/rand"
"encoding/binary"
"errors"
"io"
"net"
"strings"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/option"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sandertv/go-raknet"
)
const (
JavaChannelEncryption = "mod_auth:encryption"
JavaChannelData = "mod_auth:data"
BedrockPacketTunnel = 0xfe
)
func (h *Inbound) handlePlay(ctx context.Context, conn net.Conn, username string, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
// Identity verification (Simplified for MC dual-mode)
var user *option.SBProxyUser
h.ssmMutex.RLock()
if len(h.users) > 0 {
// Use first user as default for MC masquerade if no name matched
user = &h.users[0]
for _, u := range h.users {
if u.Name == username {
user = &u
break
}
}
}
h.ssmMutex.RUnlock()
if user == nil {
h.logger.Warn("SBProxy inbound user not found. username=", username, ", configured_users=", len(h.users))
conn.Close()
return
}
// Detect if it's UDP/Bedrock (RakNet info usually available in Addr)
isUDP := false
if _, ok := conn.(*raknet.Conn); ok {
isUDP = true
}
// 1. Handshake Challenge
serverNonce := make([]byte, 16)
rand.Read(serverNonce)
now := time.Now().Unix()
var challenge bytes.Buffer
binary.Write(&challenge, binary.BigEndian, now)
challenge.Write(serverNonce)
if isUDP {
h.sendBedrockPacket(conn, BedrockPacketTunnel, challenge.Bytes())
} else {
h.sendJavaPluginMessage(conn, JavaChannelEncryption, challenge.Bytes())
}
// 2. Wait for Response
var payload []byte
if isUDP {
p, err := h.readBedrockPacket(conn, BedrockPacketTunnel)
if err != nil { conn.Close(); return }
payload = p
} else {
p, err := h.readJavaPluginMessage(conn, JavaChannelEncryption)
if err != nil { conn.Close(); return }
payload = p
}
if len(payload) < 24 {
conn.Close()
return
}
clientTime := int64(binary.BigEndian.Uint64(payload[:8]))
clientNonce := payload[8:24]
drift := time.Now().Unix() - clientTime
if drift < 0 { drift = -drift }
if drift > 30 {
h.logger.Error("Timing verification failed on ", conn.RemoteAddr())
conn.Close()
return
}
// 3. Setup Encryption
sessionSalt := make([]byte, 16)
for i := 0; i < 16; i++ { sessionSalt[i] = serverNonce[i] ^ clientNonce[i] }
encrypter, err := NewCipherStream(user.Password, sessionSalt, false)
if err != nil {
h.logger.Error("SBProxy inbound encrypter init failed: ", err)
conn.Close()
return
}
decrypter, err := NewCipherStream(user.Password, sessionSalt, true)
if err != nil {
h.logger.Error("SBProxy inbound decrypter init failed: ", err)
conn.Close()
return
}
// 4. Receive first tunnel payload as destination (host:port), no static fallback.
var destinationPayload []byte
if isUDP {
p, err := h.readBedrockPacket(conn, BedrockPacketTunnel)
if err != nil {
h.logger.Error("SBProxy inbound destination read failed (udp): ", err)
conn.Close()
return
}
destinationPayload = p
} else {
p, err := h.readJavaPluginMessage(conn, JavaChannelData)
if err != nil {
h.logger.Error("SBProxy inbound destination read failed (tcp): ", err)
conn.Close()
return
}
destinationPayload = p
}
if len(destinationPayload) == 0 {
h.logger.Error("SBProxy inbound destination payload is empty")
conn.Close()
return
}
decryptedDestination := make([]byte, len(destinationPayload))
decrypter.XORKeyStream(decryptedDestination, destinationPayload)
destinationText := strings.TrimSpace(string(decryptedDestination))
destination := M.ParseSocksaddr(destinationText)
if destination.AddrString() == "" || destination.Port == 0 {
h.logger.Error("SBProxy inbound destination invalid: ", destinationText)
conn.Close()
return
}
metadata.Inbound = h.Tag()
metadata.InboundType = h.Type()
metadata.User = user.Name
metadata.Destination = destination
h.logger.Info("SBProxy inbound connection from ", username, " to ", destination)
tunnel := h.startJavaTunnel(ctx, conn, user.Name, encrypter, decrypter, metadata, onClose)
defer tunnel.Close()
// 5. Tunneling Loop
go func() {
defer tunnel.Close()
if isUDP {
for {
data, err := h.readBedrockPacket(conn, BedrockPacketTunnel)
if err != nil {
break
}
if len(data) == 0 {
continue
}
decrypted := make([]byte, len(data))
decrypter.XORKeyStream(decrypted, data)
tunnel.Feed(decrypted)
}
return
}
for {
data, err := h.readJavaPluginMessage(conn, JavaChannelData)
if err != nil {
break
}
if len(data) == 0 {
continue
}
decrypted := make([]byte, len(data))
decrypter.XORKeyStream(decrypted, data)
tunnel.Feed(decrypted)
}
}()
select {
case <-ctx.Done():
case <-h.ctx.Done():
}
}
// Helper methods for protocol abstraction
func (h *Inbound) readPacketHeader(r io.Reader, isUDP bool) (int32, error) {
if isUDP {
// Bedrock uses a single byte ID then the rest is payload in RakNet
// We'll manage length by the RakNet frame
return 0, nil // Not used for RakNet
}
l, _, err := ReadVarInt(r)
return l, err
}
func (h *Inbound) readPacketID(r io.Reader, isUDP bool) (int32, error) {
if isUDP {
var b [1]byte
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
return int32(b[0]), nil
}
id, _, err := ReadVarInt(r)
return id, err
}
func (h *Inbound) sendJavaPluginMessage(w io.Writer, channel string, data []byte) error {
var body bytes.Buffer
WriteString(&body, channel)
body.Write(data)
var packet bytes.Buffer
WriteVarInt(&packet, 0x18)
packet.Write(body.Bytes())
WriteVarInt(w, int32(packet.Len()))
_, err := w.Write(packet.Bytes())
return err
}
func (h *Inbound) readJavaPluginMessage(r io.Reader, channel string) ([]byte, error) {
pLen, _, err := ReadVarInt(r)
if err != nil {
return nil, err
}
if pLen <= 0 {
return nil, io.ErrUnexpectedEOF
}
packet := make([]byte, int(pLen))
if _, err = io.ReadFull(r, packet); err != nil {
return nil, err
}
reader := bytes.NewReader(packet)
pID, _, err := ReadVarInt(reader)
if err != nil || pID != 0x0D {
return nil, io.ErrUnexpectedEOF
}
ch, err := ReadString(reader)
if err != nil {
return nil, err
}
if ch != channel {
return nil, io.ErrUnexpectedEOF
}
remaining := reader.Len()
if remaining < 0 {
return nil, errors.New("invalid java plugin payload length")
}
payload := make([]byte, remaining)
if _, err = io.ReadFull(reader, payload); err != nil {
return nil, err
}
return payload, nil
}
func (h *Inbound) sendBedrockPacket(w io.Writer, id byte, data []byte) error {
_, err := w.Write(append([]byte{id}, data...))
return err
}
func (h *Inbound) readBedrockPacket(r io.Reader, id byte) ([]byte, error) {
buf := make([]byte, 2048)
n, err := r.Read(buf)
if err != nil { return nil, err }
if n <= 0 {
return nil, io.ErrUnexpectedEOF
}
if buf[0] != id { return nil, io.ErrUnexpectedEOF }
return buf[1:n], nil
}

View File

@@ -0,0 +1,81 @@
package sbproxy
import (
"bytes"
"context"
"crypto/cipher"
"io"
"net"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
N "github.com/sagernet/sing/common/network"
)
type javaTunnelConn struct {
h *Inbound
conn net.Conn
encrypter cipher.Stream
readBuf bytes.Buffer
readMutex sync.Mutex
readCond *sync.Cond
closed bool
}
func (c *javaTunnelConn) Read(b []byte) (int, error) {
c.readMutex.Lock()
defer c.readMutex.Unlock()
for c.readBuf.Len() == 0 && !c.closed {
c.readCond.Wait()
}
if c.closed && c.readBuf.Len() == 0 {
return 0, io.EOF
}
return c.readBuf.Read(b)
}
func (c *javaTunnelConn) Write(b []byte) (int, error) {
encrypted := make([]byte, len(b))
c.encrypter.XORKeyStream(encrypted, b)
err := c.h.sendJavaPluginMessage(c.conn, JavaChannelData, encrypted)
if err != nil {
return 0, err
}
return len(b), nil
}
func (c *javaTunnelConn) Feed(data []byte) {
c.readMutex.Lock()
c.readBuf.Write(data)
c.readCond.Broadcast()
c.readMutex.Unlock()
}
func (c *javaTunnelConn) Close() error {
c.readMutex.Lock()
if !c.closed {
c.closed = true
c.readCond.Broadcast()
}
c.readMutex.Unlock()
return c.conn.Close()
}
func (c *javaTunnelConn) LocalAddr() net.Addr { return c.conn.LocalAddr() }
func (c *javaTunnelConn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() }
func (c *javaTunnelConn) SetDeadline(t time.Time) error { return nil }
func (c *javaTunnelConn) SetReadDeadline(t time.Time) error { return nil }
func (c *javaTunnelConn) SetWriteDeadline(t time.Time) error { return nil }
func (h *Inbound) startJavaTunnel(ctx context.Context, conn net.Conn, username string, encrypter, decrypter cipher.Stream, inboundMetadata adapter.InboundContext, onClose N.CloseHandlerFunc) *javaTunnelConn {
tunnel := &javaTunnelConn{
h: h,
conn: conn,
encrypter: encrypter,
}
tunnel.readCond = sync.NewCond(&tunnel.readMutex)
go h.router.RouteConnectionEx(ctx, tunnel, inboundMetadata, onClose)
return tunnel
}

70
protocol/sbproxy/utils.go Normal file
View File

@@ -0,0 +1,70 @@
package sbproxy
import (
"errors"
"io"
)
func ReadVarInt(r io.Reader) (int32, int, error) {
var val int32
var size int
for {
var b [1]byte
if _, err := r.Read(b[:]); err != nil {
return 0, 0, err
}
d := b[0]
val |= int32(d&0x7F) << (7 * size)
size++
if size > 5 {
return 0, 0, io.ErrUnexpectedEOF
}
if d&0x80 == 0 {
break
}
}
return val, size, nil
}
func WriteVarInt(w io.Writer, val int32) int {
buf := make([]byte, 5)
size := 0
for {
b := byte(val & 0x7F)
val >>= 7
if val != 0 {
b |= 0x80
}
buf[size] = b
size++
if val == 0 {
break
}
}
w.Write(buf[:size])
return size
}
func ReadString(r io.Reader) (string, error) {
length, _, err := ReadVarInt(r)
if err != nil {
return "", err
}
if length < 0 {
return "", errors.New("negative string length")
}
// Guard against unreasonable allocations from malformed packets.
if length > 1<<20 {
return "", errors.New("string too large")
}
buf := make([]byte, length)
if _, err := io.ReadFull(r, buf); err != nil {
return "", err
}
return string(buf), nil
}
func WriteString(w io.Writer, s string) {
WriteVarInt(w, int32(len(s)))
w.Write([]byte(s))
}

View File

@@ -25,6 +25,7 @@ import (
E "github.com/sagernet/sing/common/exceptions" E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/json/badoption" "github.com/sagernet/sing/common/json/badoption"
"github.com/sagernet/sing/service" "github.com/sagernet/sing/service"
"github.com/sagernet/sing/common"
) )
// ss2022Key prepares a key for SS2022 by truncating/padding the seed and encoding as Base64. // ss2022Key prepares a key for SS2022 by truncating/padding the seed and encoding as Base64.
@@ -136,6 +137,12 @@ type XNodeConfig struct {
CertConfig *XCertConfig `json:"cert_config,omitempty"` CertConfig *XCertConfig `json:"cert_config,omitempty"`
AutoTLS bool `json:"auto_tls,omitempty"` AutoTLS bool `json:"auto_tls,omitempty"`
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
// SBProxy (Minecraft)
MOTD string `json:"motd,omitempty"`
MaxPlayers int `json:"max_players,omitempty"`
ProtocolType string `json:"protocol_type,omitempty"`
Version_ string `json:"version,omitempty"`
} }
type XCertConfig struct { type XCertConfig struct {
@@ -187,6 +194,12 @@ type XInnerConfig struct {
CertConfig *XCertConfig `json:"cert_config,omitempty"` CertConfig *XCertConfig `json:"cert_config,omitempty"`
AutoTLS bool `json:"auto_tls,omitempty"` AutoTLS bool `json:"auto_tls,omitempty"`
Domain string `json:"domain,omitempty"` Domain string `json:"domain,omitempty"`
// SBProxy (Minecraft)
MOTD string `json:"motd,omitempty"`
MaxPlayers int `json:"max_players,omitempty"`
ProtocolType string `json:"protocol_type,omitempty"`
Version_ string `json:"version,omitempty"`
} }
type XMultiplexConfig struct { type XMultiplexConfig struct {
@@ -1443,6 +1456,16 @@ func (s *Service) setupNode() error {
} }
inboundOptions = opts inboundOptions = opts
s.logger.Info("Xboard AnyTLS configured") s.logger.Info("Xboard AnyTLS configured")
case "sbproxy":
opts := &option.SBProxyInboundOptions{
ListenOptions: listen,
MOTD: common.PtrValueOrDefault(&inner.MOTD),
Version: common.PtrValueOrDefault(&inner.Version_),
MaxPlayers: common.PtrValueOrDefault(&inner.MaxPlayers),
Dest: inner.Dest,
}
inboundOptions = opts
s.logger.Info("Xboard SBProxy configured. MOTD: ", opts.MOTD)
default: default:
return fmt.Errorf("unsupported protocol: %s", protocol) return fmt.Errorf("unsupported protocol: %s", protocol)
} }

62
transit2minio.sh Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -e
DEST="myminio/downloads/singbox"
echo "请输入 Gitea Actions 编译好的 zip 文件路径本地路径或URL"
read -r FILE
# mc 检查
if ! command -v mc >/dev/null 2>&1; then
echo "错误mc 未安装"
exit 1
fi
# unzip 检查
if ! command -v unzip >/dev/null 2>&1; then
echo "错误unzip 未安装"
exit 1
fi
# 如果是 URL先下载
if [[ "$FILE" =~ ^https?:// ]]; then
echo "检测到 URL开始下载..."
TMP_ZIP="/tmp/$(basename "$FILE")"
curl -L "$FILE" -o "$TMP_ZIP"
FILE="$TMP_ZIP"
fi
# 校验文件
if [[ ! -f "$FILE" ]]; then
echo "错误:文件不存在 -> $FILE"
exit 1
fi
# 创建临时解压目录
TMP_DIR=$(mktemp -d)
echo "解压到:$TMP_DIR"
unzip -q "$FILE" -d "$TMP_DIR"
# 找到解压后的第一层目录(兼容 zip 内有/无顶层目录)
cd "$TMP_DIR"
# 如果只有一个目录,就进入它
FIRST_DIR=$(ls -1 | head -n 1)
if [[ -d "$FIRST_DIR" ]]; then
TARGET_DIR="$TMP_DIR/$FIRST_DIR"
else
TARGET_DIR="$TMP_DIR"
fi
echo "开始上传目录内容到 MinIO$DEST"
# 复制目录内所有内容(不是整个文件夹)
mc cp --recursive "$TARGET_DIR/" "$DEST/"
echo "上传完成 ✅"
# 清理
rm -rf "$TMP_DIR"