Skip to content

Commit 648fb84

Browse files
committed
feat: 新增 AnyTLS 協議(sing-box 引擎)
NodeProtocol 加 anytls;接上節點編輯器、URI(anytls://)、Clash YAML (type: anytls)、sing-box 設定產生、分享連結、sing-box 節點匯入。 AnyTLS 強制走 TLS。用真實 sing-box 1.13.13 驗證產生的設定(check exit 0)。 ShadowTLS 為 wrapper 型協議(需 detour 串接另一出站),與單節點模型不合, 原生引擎版 ShadowTLS/AnyTLS 涉及模仿握手與 padding mux、且無測試伺服器, 誠實留作後續較大工程;現階段這些協議走 sing-box 引擎。
1 parent 6d2d622 commit 648fb84

9 files changed

Lines changed: 125 additions & 6 deletions

File tree

Sources/ShadowSpaceKit/App/NativeEngineAdapter.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ enum NativeEngineAdapter {
5151
throw AdapterError.unsupported("原生引擎不支援 Hysteria2(QUIC),請改用 sing-box 引擎")
5252
case .tuic:
5353
throw AdapterError.unsupported("原生引擎不支援 TUIC(QUIC),請改用 sing-box 引擎")
54+
case .anytls:
55+
throw AdapterError.unsupported("原生引擎不支援 AnyTLS,請改用 sing-box 引擎")
5456
case .wireguard:
5557
throw AdapterError.unsupported("原生引擎不支援 WireGuard(需 Packet Tunnel),請改用 sing-box 引擎")
5658
}

Sources/ShadowSpaceKit/Core/ClashYAMLParser.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,15 @@ enum ClashYAMLParser {
235235
node.password = str(m, "password")
236236
return node
237237

238+
case "anytls":
239+
var node = ProxyNode(name: name, proto: .anytls, server: server, port: port)
240+
node.password = str(m, "password") ?? ""
241+
node.tls = true
242+
node.sni = str(m, "sni") ?? str(m, "servername")
243+
node.insecure = bool(m, "skip-cert-verify")
244+
node.alpn = list(m, "alpn")
245+
return node
246+
238247
case "hysteria2", "hy2":
239248
var node = ProxyNode(name: name, proto: .hysteria2, server: server, port: port)
240249
node.password = str(m, "password") ?? str(m, "auth") ?? ""

Sources/ShadowSpaceKit/Core/NodeShare.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ enum NodeShare {
100100
if node.insecure { items.append(("allow_insecure", "1")) }
101101
return "tuic://\(encodeComponent(node.uuid ?? "")):\(encodeComponent(node.password ?? ""))@\(host):\(node.port)\(queryString(items))\(name)"
102102

103+
case .anytls:
104+
var items: [(String, String?)] = [("sni", node.sni)]
105+
if node.insecure { items.append(("allowInsecure", "1")) }
106+
if let alpn = node.alpn, !alpn.isEmpty { items.append(("alpn", alpn.joined(separator: ","))) }
107+
return "anytls://\(encodeComponent(node.password ?? ""))@\(host):\(node.port)\(queryString(items))\(name)"
108+
103109
case .socks:
104110
if let user = node.username {
105111
let userinfo = Data("\(user):\(node.password ?? "")".utf8).base64EncodedString()

Sources/ShadowSpaceKit/Core/SingBoxConfigBuilder.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,14 +389,17 @@ enum SingBoxConfigBuilder {
389389
ob["uuid"] = node.uuid ?? ""
390390
ob["password"] = node.password ?? ""
391391
if let cc = node.congestionControl { ob["congestion_control"] = cc }
392+
case .anytls:
393+
ob["type"] = "anytls"
394+
ob["password"] = node.password ?? ""
392395
case .socks:
393396
ob["type"] = "socks"
394397
ob["version"] = "5"
395398
if let user = node.username { ob["username"] = user }
396399
if let pw = node.password { ob["password"] = pw }
397400
}
398401

399-
if node.tls {
402+
if node.tls || node.proto == .anytls {
400403
var tls: [String: Any] = [
401404
"enabled": true,
402405
"server_name": node.sni ?? node.wsHost ?? node.server,

Sources/ShadowSpaceKit/Core/SingBoxNodeParser.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ enum SingBoxNodeParser {
3939
case "hysteria2": proto = .hysteria2
4040
case "tuic": proto = .tuic
4141
case "socks": proto = .socks
42+
case "anytls": proto = .anytls
4243
default: return nil // selector/urltest/direct/block/dns 等不是節點
4344
}
4445
var node = ProxyNode(name: name, proto: proto, server: server, port: port)
@@ -54,7 +55,7 @@ enum SingBoxNodeParser {
5455
case .vless:
5556
node.uuid = ob["uuid"] as? String
5657
node.flow = ob["flow"] as? String
57-
case .trojan:
58+
case .trojan, .anytls:
5859
node.password = ob["password"] as? String
5960
case .hysteria2:
6061
node.password = ob["password"] as? String

Sources/ShadowSpaceKit/Core/URIParser.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ enum URIParser {
8282
case "hysteria2", "hy2": return parseHysteria2(s)
8383
case "tuic": return parseTUIC(s)
8484
case "socks", "socks5": return parseSOCKS(s)
85+
case "anytls": return parseAnyTLS(s)
8586
default: return nil
8687
}
8788
}
@@ -340,6 +341,27 @@ enum URIParser {
340341
return node
341342
}
342343

344+
// MARK: - AnyTLS
345+
346+
/// anytls://password@host:port?sni=&insecure=&alpn=#name
347+
static func parseAnyTLS(_ raw: String) -> ProxyNode? {
348+
guard let parts = splitURI(raw),
349+
let pw = parts.userinfo, !pw.isEmpty, parts.port > 0 else { return nil }
350+
var node = ProxyNode(
351+
name: parts.fragment ?? "\(parts.host):\(parts.port)",
352+
proto: .anytls, server: parts.host, port: parts.port
353+
)
354+
node.password = pw.removingPercentEncoding ?? pw
355+
node.tls = true
356+
let q = parts.query
357+
node.sni = q["sni"] ?? q["peer"]
358+
node.insecure = ["1", "true"].contains((q["allowInsecure"] ?? q["insecure"] ?? "").lowercased())
359+
if let alpn = q["alpn"], !alpn.isEmpty {
360+
node.alpn = alpn.split(separator: ",").map(String.init)
361+
}
362+
return node
363+
}
364+
343365
// MARK: - SOCKS
344366

345367
static func parseSOCKS(_ raw: String) -> ProxyNode? {

Sources/ShadowSpaceKit/Models/Models.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ enum NodeProtocol: String, Codable, CaseIterable {
99
case trojan
1010
case hysteria2
1111
case tuic
12+
case anytls
1213
case socks
1314
case wireguard
1415

@@ -20,6 +21,7 @@ enum NodeProtocol: String, Codable, CaseIterable {
2021
case .trojan: return "Trojan"
2122
case .hysteria2: return "Hysteria2"
2223
case .tuic: return "TUIC"
24+
case .anytls: return "AnyTLS"
2325
case .socks: return "SOCKS"
2426
case .wireguard: return "WireGuard"
2527
}

Sources/ShadowSpaceKit/Views/NodeEditorSheet.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ struct NodeEditorSheet: View {
7979
return !(draft.method ?? "").isEmpty && !(draft.password ?? "").isEmpty
8080
case .vmess, .vless:
8181
return !(draft.uuid ?? "").isEmpty
82-
case .trojan, .hysteria2:
82+
case .trojan, .hysteria2, .anytls:
8383
return !(draft.password ?? "").isEmpty
8484
case .tuic:
8585
return !(draft.uuid ?? "").isEmpty
@@ -120,7 +120,7 @@ struct NodeEditorSheet: View {
120120
Text("").tag("")
121121
Text("xtls-rprx-vision").tag("xtls-rprx-vision")
122122
}
123-
case .trojan:
123+
case .trojan, .anytls:
124124
SecureInput("密碼", text: optional($draft.password))
125125
case .hysteria2:
126126
SecureInput("密碼", text: optional($draft.password))
@@ -149,14 +149,14 @@ struct NodeEditorSheet: View {
149149

150150
private var showsTLSSection: Bool {
151151
switch draft.proto {
152-
case .vmess, .vless, .trojan, .hysteria2, .tuic: return true
152+
case .vmess, .vless, .trojan, .hysteria2, .tuic, .anytls: return true
153153
case .shadowsocks, .socks, .wireguard: return false
154154
}
155155
}
156156

157157
private var tlsForced: Bool {
158158
switch draft.proto {
159-
case .trojan, .hysteria2, .tuic: return true
159+
case .trojan, .hysteria2, .tuic, .anytls: return true
160160
default: return false
161161
}
162162
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import XCTest
2+
@testable import ShadowSpaceKit
3+
4+
final class AnyTLSTests: XCTestCase {
5+
6+
func testParseAnyTLSURI() throws {
7+
let node = try XCTUnwrap(URIParser.parseAnyTLS(
8+
"anytls://mypassword@anytls.example.com:8443?sni=example.com&insecure=1#AnyTLS-Node"))
9+
XCTAssertEqual(node.proto, .anytls)
10+
XCTAssertEqual(node.name, "AnyTLS-Node")
11+
XCTAssertEqual(node.server, "anytls.example.com")
12+
XCTAssertEqual(node.port, 8443)
13+
XCTAssertEqual(node.password, "mypassword")
14+
XCTAssertTrue(node.tls)
15+
XCTAssertEqual(node.sni, "example.com")
16+
XCTAssertTrue(node.insecure)
17+
}
18+
19+
func testParseAnyTLSViaGenericEntry() {
20+
// 經由 URIParser.parse 分派
21+
let node = URIParser.parse("anytls://pw@h.com:443#x")
22+
XCTAssertEqual(node?.proto, .anytls)
23+
}
24+
25+
func testClashAnyTLS() throws {
26+
let yaml = """
27+
proxies:
28+
- name: AnyTLS-A
29+
type: anytls
30+
server: a.example.com
31+
port: 443
32+
password: secret
33+
sni: a.example.com
34+
skip-cert-verify: true
35+
"""
36+
let node = try XCTUnwrap(ClashYAMLParser.parse(yaml).first)
37+
XCTAssertEqual(node.proto, .anytls)
38+
XCTAssertEqual(node.password, "secret")
39+
XCTAssertTrue(node.tls)
40+
XCTAssertTrue(node.insecure)
41+
}
42+
43+
func testShareLinkRoundTrip() throws {
44+
let original = try XCTUnwrap(URIParser.parseAnyTLS(
45+
"anytls://pw123@srv.example.com:8443?sni=srv.example.com#Node"))
46+
let link = try XCTUnwrap(NodeShare.uri(for: original))
47+
XCTAssertTrue(link.hasPrefix("anytls://"))
48+
let parsed = try XCTUnwrap(URIParser.parse(link))
49+
XCTAssertEqual(parsed.proto, .anytls)
50+
XCTAssertEqual(parsed.password, "pw123")
51+
XCTAssertEqual(parsed.server, "srv.example.com")
52+
XCTAssertEqual(parsed.sni, "srv.example.com")
53+
}
54+
55+
/// 產出含 anytls outbound 的 sing-box 設定,欄位正確,並寫到 /tmp 供 `sing-box check`。
56+
func testAnyTLSConfigStructureAndDump() throws {
57+
let node = try XCTUnwrap(URIParser.parseAnyTLS(
58+
"anytls://mypassword@anytls.example.com:8443?sni=example.com&insecure=1#AnyTLS"))
59+
let result = SingBoxConfigBuilder.build(
60+
nodes: [node], selectedID: node.id, settings: AppSettings(), mode: .rule)
61+
let data = try SingBoxConfigBuilder.jsonData(result.json)
62+
let obj = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
63+
let outbounds = try XCTUnwrap(obj["outbounds"] as? [[String: Any]])
64+
let anytls = try XCTUnwrap(outbounds.first { $0["type"] as? String == "anytls" })
65+
XCTAssertEqual(anytls["password"] as? String, "mypassword")
66+
XCTAssertEqual(anytls["server"] as? String, "anytls.example.com")
67+
XCTAssertEqual(anytls["server_port"] as? Int, 8443)
68+
let tls = try XCTUnwrap(anytls["tls"] as? [String: Any])
69+
XCTAssertEqual(tls["enabled"] as? Bool, true)
70+
XCTAssertEqual(tls["server_name"] as? String, "example.com")
71+
XCTAssertEqual(tls["insecure"] as? Bool, true)
72+
try data.write(to: URL(fileURLWithPath: "/tmp/shadowspace-anytls-check.json"))
73+
}
74+
}

0 commit comments

Comments
 (0)