Add AnyTLS Clash YAML support for sing-box

This commit is contained in:
lux
2026-05-30 21:12:25 +08:00
parent 4b252ee697
commit 900c3d6a4f
3 changed files with 237 additions and 1 deletions
@@ -0,0 +1,142 @@
package proxy
import "testing"
func TestBuildSingBoxAnyTLSFromClash(t *testing.T) {
src := `
proxies:
- name: anytls-main
type: anytls
server: anytls.example.com
port: 443
password: test-password
sni: sni.example.com
servername: fallback.example.com
skip-cert-verify: true
alpn:
- h2
- http/1.1
client-fingerprint: chrome
idle-session-check-interval: 30
idle-session-timeout: 45
min-idle-session: 5
`
if !IsSingBoxProtocol(src) {
t.Fatalf("expected anytls Clash YAML to be treated as sing-box protocol")
}
if RequiresBridge(src, nil, "") {
t.Fatalf("anytls Clash YAML must not require Xray bridge")
}
out, err := BuildSingBoxOutbound(src)
if err != nil {
t.Fatalf("BuildSingBoxOutbound returned error: %v", err)
}
if got := out["type"]; got != "anytls" {
t.Fatalf("type = %v, want anytls", got)
}
if got := out["tag"]; got != "proxy-out" {
t.Fatalf("tag = %v, want proxy-out", got)
}
if got := out["server"]; got != "anytls.example.com" {
t.Fatalf("server = %v, want anytls.example.com", got)
}
if got := out["server_port"]; got != 443 {
t.Fatalf("server_port = %v, want 443", got)
}
if got := out["password"]; got != "test-password" {
t.Fatalf("password = %v, want test-password", got)
}
if got := out["idle_session_check_interval"]; got != "30s" {
t.Fatalf("idle_session_check_interval = %v, want 30s", got)
}
if got := out["idle_session_timeout"]; got != "45s" {
t.Fatalf("idle_session_timeout = %v, want 45s", got)
}
if got := out["min_idle_session"]; got != 5 {
t.Fatalf("min_idle_session = %v, want 5", got)
}
tls, ok := out["tls"].(map[string]interface{})
if !ok {
t.Fatalf("tls is %T, want map[string]interface{}", out["tls"])
}
if got := tls["enabled"]; got != true {
t.Fatalf("tls.enabled = %v, want true", got)
}
if got := tls["insecure"]; got != true {
t.Fatalf("tls.insecure = %v, want true", got)
}
if got := tls["server_name"]; got != "sni.example.com" {
t.Fatalf("tls.server_name = %v, want sni.example.com", got)
}
alpn, ok := tls["alpn"].([]string)
if !ok {
t.Fatalf("tls.alpn is %T, want []string", tls["alpn"])
}
if len(alpn) != 2 || alpn[0] != "h2" || alpn[1] != "http/1.1" {
t.Fatalf("tls.alpn = %#v, want [h2 http/1.1]", alpn)
}
utls, ok := tls["utls"].(map[string]interface{})
if !ok {
t.Fatalf("tls.utls is %T, want map[string]interface{}", tls["utls"])
}
if got := utls["enabled"]; got != true {
t.Fatalf("tls.utls.enabled = %v, want true", got)
}
if got := utls["fingerprint"]; got != "chrome" {
t.Fatalf("tls.utls.fingerprint = %v, want chrome", got)
}
}
func TestBuildSingBoxAnyTLSServernameFallbackAndDurationStrings(t *testing.T) {
src := `
type: anytls
server: anytls.example.com
port: 8443
password: test-password
servername: fallback.example.com
idle-session-check-interval: 1m
idle-session-timeout: "30"
min-idle-session: 0
`
out, err := BuildSingBoxOutbound(src)
if err != nil {
t.Fatalf("BuildSingBoxOutbound returned error: %v", err)
}
if got := out["idle_session_check_interval"]; got != "1m" {
t.Fatalf("idle_session_check_interval = %v, want 1m", got)
}
if got := out["idle_session_timeout"]; got != "30s" {
t.Fatalf("idle_session_timeout = %v, want 30s", got)
}
if _, ok := out["min_idle_session"]; ok {
t.Fatalf("min_idle_session should be omitted when Clash value is 0")
}
tls, ok := out["tls"].(map[string]interface{})
if !ok {
t.Fatalf("tls is %T, want map[string]interface{}", out["tls"])
}
if got := tls["server_name"]; got != "fallback.example.com" {
t.Fatalf("tls.server_name = %v, want fallback.example.com", got)
}
if got := tls["insecure"]; got != false {
t.Fatalf("tls.insecure = %v, want false", got)
}
if _, ok := tls["utls"]; ok {
t.Fatalf("tls.utls should be omitted without client-fingerprint")
}
}
func TestBuildSingBoxAnyTLSRequiresServerAndPort(t *testing.T) {
src := `
type: anytls
password: test-password
`
if _, err := BuildSingBoxOutbound(src); err == nil {
t.Fatalf("expected missing server and port to fail")
}
}
+92 -1
View File
@@ -18,7 +18,8 @@ func IsSingBoxProtocol(proxyConfig string) bool {
// Clash YAML 格式
if strings.Contains(l, "type: hysteria2") || strings.Contains(l, "type:hysteria2") ||
strings.Contains(l, "type: hysteria") || strings.Contains(l, "type:hysteria") ||
strings.Contains(l, "type: tuic") || strings.Contains(l, "type:tuic") {
strings.Contains(l, "type: tuic") || strings.Contains(l, "type:tuic") ||
strings.Contains(l, "type: anytls") || strings.Contains(l, "type:anytls") {
return true
}
return false
@@ -120,11 +121,67 @@ func parseClashSingBoxNode(src string) (map[string]interface{}, error) {
return buildSingBoxHysteria2FromClash(nodeMap)
case "tuic":
return buildSingBoxTUICFromClash(nodeMap)
case "anytls":
return buildSingBoxAnyTLSFromClash(nodeMap)
default:
return nil, fmt.Errorf("不支持的 sing-box 节点类型: %s", nodeType)
}
}
func buildSingBoxAnyTLSFromClash(node map[string]interface{}) (map[string]interface{}, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
password := getMapString(node, "password")
sni := getMapString(node, "sni")
if sni == "" {
sni = getMapString(node, "servername")
}
skipVerify := getMapBool(node, "skip-cert-verify")
if host == "" || port == 0 {
return nil, fmt.Errorf("anytls node info incomplete")
}
tls := map[string]interface{}{
"enabled": true,
"insecure": skipVerify,
}
if sni != "" {
tls["server_name"] = sni
}
if alpnRaw, ok := node["alpn"]; ok {
if alpnList := toStringSlice(alpnRaw); len(alpnList) > 0 {
tls["alpn"] = alpnList
}
}
if fingerprint := getMapString(node, "client-fingerprint"); fingerprint != "" {
tls["utls"] = map[string]interface{}{
"enabled": true,
"fingerprint": fingerprint,
}
}
out := map[string]interface{}{
"type": "anytls",
"tag": "proxy-out",
"server": host,
"server_port": port,
"password": password,
"tls": tls,
}
if interval := clashDurationSecondsString(node, "idle-session-check-interval"); interval != "" {
out["idle_session_check_interval"] = interval
}
if timeout := clashDurationSecondsString(node, "idle-session-timeout"); timeout != "" {
out["idle_session_timeout"] = timeout
}
if minIdleSession := getMapInt(node, "min-idle-session"); minIdleSession != 0 {
out["min_idle_session"] = minIdleSession
}
return out, nil
}
func buildSingBoxHysteria2FromClash(node map[string]interface{}) (map[string]interface{}, error) {
host := getMapString(node, "server")
port := getMapInt(node, "port")
@@ -227,6 +284,40 @@ func parseBandwidthMbps(s string) int {
return n
}
func clashDurationSecondsString(node map[string]interface{}, key string) string {
v, ok := node[key]
if !ok {
return ""
}
switch value := v.(type) {
case int:
if value <= 0 {
return ""
}
return fmt.Sprintf("%ds", value)
case int64:
if value <= 0 {
return ""
}
return fmt.Sprintf("%ds", value)
case float64:
if value <= 0 {
return ""
}
return fmt.Sprintf("%ds", int(value))
case string:
s := strings.TrimSpace(value)
if s == "" || s == "0" {
return ""
}
if _, err := strconv.Atoi(s); err == nil {
return s + "s"
}
return s
}
return ""
}
// toStringSlice 将 interface{} 转为 []string
func toStringSlice(v interface{}) []string {
if v == nil {
+3
View File
@@ -104,6 +104,9 @@ func RequiresBridge(proxyConfig string, proxies []config.BrowserProxy, proxyId s
if IsChainSocks5Proxy(src) {
return true
}
if IsSingBoxProtocol(src) {
return false
}
if strings.HasPrefix(l, "hysteria://") || strings.HasPrefix(l, "hysteria2://") {
return false
}