mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 20:34:50 +00:00
Add Mainland China proxy routing with independent rule workspaces. Preserve existing routing choices through equivalent preset mappings. BREAKING CHANGE: existing custom rules and enablement settings are not migrated. Recreate them in the appropriate preset.
280 lines
9.3 KiB
Swift
280 lines
9.3 KiB
Swift
import Darwin
|
|
import Foundation
|
|
|
|
// MARK: - 动作
|
|
|
|
/// 规则动作:目标经 Shadowsocks 代理,或直连。独立于 PAC、ACL 文本与
|
|
/// 系统代理设置。
|
|
enum RuleAction: String, CaseIterable, Equatable, Hashable, Sendable {
|
|
case proxy
|
|
case direct
|
|
}
|
|
|
|
/// 规则模式的未匹配默认动作:未命中规则时走代理或直连。
|
|
/// 出厂默认「未匹配时代理」。
|
|
enum RuleDefaultAction: String, Codable, CaseIterable, Equatable, Hashable, Sendable {
|
|
case proxyWhenUnmatched = "proxy-when-unmatched"
|
|
case directWhenUnmatched = "direct-when-unmatched"
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .proxyWhenUnmatched: String(localized: "代理")
|
|
case .directWhenUnmatched: String(localized: "直连")
|
|
}
|
|
}
|
|
|
|
func expresses(_ action: RuleAction) -> Bool {
|
|
self != .proxyWhenUnmatched || action == .direct
|
|
}
|
|
|
|
/// 域名优先匹配下,未命中时的默认动作。
|
|
var fallbackAction: RuleAction {
|
|
switch self {
|
|
case .proxyWhenUnmatched: .proxy
|
|
case .directWhenUnmatched: .direct
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - 匹配条件
|
|
|
|
/// 规则匹配条件。完整域名、域名后缀与 IPv4/IPv6 CIDR 是可运行的统一类型;
|
|
/// 不把 URL 路径、协议、关键字或正则纳入。
|
|
struct RuleMatch: Equatable, Hashable, Sendable {
|
|
/// Read-only pattern; raw strings cannot be used to construct a RuleMatch.
|
|
enum Pattern: Equatable, Hashable, Sendable {
|
|
case domainExact(String)
|
|
case domainSuffix(String)
|
|
case ipv4CIDR(String)
|
|
case ipv6CIDR(String)
|
|
}
|
|
|
|
let pattern: Pattern
|
|
|
|
private init(pattern: Pattern) { self.pattern = pattern }
|
|
|
|
static let localSuffix = RuleMatch(pattern: .domainSuffix("local"))
|
|
static let localhostSuffix = RuleMatch(pattern: .domainSuffix("localhost"))
|
|
|
|
var isFixedLocalSuffix: Bool { self == Self.localSuffix || self == Self.localhostSuffix }
|
|
|
|
init(domainExact raw: String) throws {
|
|
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !value.isEmpty else { throw RuleMatchError.emptyDomain }
|
|
guard !value.hasPrefix("."), !value.hasPrefix("*") else {
|
|
throw RuleMatchError.invalidDomain(raw)
|
|
}
|
|
guard !value.hasSuffix(".") else { throw RuleMatchError.invalidDomain(raw) }
|
|
guard value.allSatisfy({ Self.isDomainLabelCharacter($0) || $0 == "." }) else {
|
|
throw RuleMatchError.invalidDomain(raw)
|
|
}
|
|
self.init(pattern: .domainExact(value))
|
|
}
|
|
|
|
init(domainSuffix raw: String) throws {
|
|
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
while value.hasPrefix(".") { value.removeFirst() }
|
|
guard !value.isEmpty else { throw RuleMatchError.emptyDomain }
|
|
guard !value.hasPrefix("*"), !value.hasSuffix(".") else {
|
|
throw RuleMatchError.invalidDomain(raw)
|
|
}
|
|
guard value.allSatisfy({ Self.isDomainLabelCharacter($0) || $0 == "." }) else {
|
|
throw RuleMatchError.invalidDomain(raw)
|
|
}
|
|
// 单标签后缀(如 `com`)过宽;`cn` 作为国家后缀由吸收流程单独引入并显式允许。
|
|
let labels = value.split(separator: ".")
|
|
guard labels.count >= 2 else { throw RuleMatchError.invalidDomain(raw) }
|
|
self.init(pattern: .domainSuffix(value))
|
|
}
|
|
|
|
/// 允许 `cn` 作为显式引入的国家后缀。
|
|
init(nationalDomainSuffix raw: String) throws {
|
|
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard value == "cn" else {
|
|
// 回退到普通后缀校验。
|
|
try self.init(domainSuffix: raw)
|
|
return
|
|
}
|
|
self.init(pattern: .domainSuffix(value))
|
|
}
|
|
|
|
init(ipv4CIDR raw: String) throws {
|
|
let normalized = try Self.normalizeIPv4CIDR(raw)
|
|
self.init(pattern: .ipv4CIDR(normalized))
|
|
}
|
|
|
|
init(ipv6CIDR raw: String) throws {
|
|
let normalized = try Self.normalizeIPv6CIDR(raw)
|
|
self.init(pattern: .ipv6CIDR(normalized))
|
|
}
|
|
|
|
private static func isDomainLabelCharacter(_ character: Character) -> Bool {
|
|
character.isASCII && (character.isLetter || character.isNumber || character == "-")
|
|
}
|
|
|
|
private static func normalizeIPv4CIDR(_ raw: String) throws -> String {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { throw RuleMatchError.invalidCIDR(raw) }
|
|
let parts = trimmed.split(separator: "/", maxSplits: 1)
|
|
let addressPart = String(parts.first ?? "")
|
|
let prefixLength: Int
|
|
if parts.count == 2 {
|
|
guard let parsed = Int(parts[1]), (0...32).contains(parsed) else {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
prefixLength = parsed
|
|
} else {
|
|
prefixLength = 32
|
|
}
|
|
var address = in_addr()
|
|
let converted = addressPart.withCString { inet_pton(AF_INET, $0, &address) }
|
|
guard converted == 1 else {
|
|
// 确认不是 IPv6。
|
|
var probe = in6_addr()
|
|
if addressPart.withCString({ inet_pton(AF_INET6, $0, &probe) }) == 1 {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
let host = UInt32(bigEndian: address.s_addr)
|
|
let mask: UInt32 = prefixLength == 0 ? 0 : UInt32.max << (32 - prefixLength)
|
|
let network = host & mask
|
|
var networkAddress = in_addr(s_addr: network.bigEndian)
|
|
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
|
|
guard inet_ntop(AF_INET, &networkAddress, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
guard let dotted = Self.string(from: buffer) else {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
return "\(dotted)/\(prefixLength)"
|
|
}
|
|
|
|
private static func normalizeIPv6CIDR(_ raw: String) throws -> String {
|
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { throw RuleMatchError.invalidCIDR(raw) }
|
|
let parts = trimmed.split(separator: "/", maxSplits: 1)
|
|
let addressPart = String(parts.first ?? "")
|
|
let prefixLength: Int
|
|
if parts.count == 2 {
|
|
guard let parsed = Int(parts[1]), (0...128).contains(parsed) else {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
prefixLength = parsed
|
|
} else {
|
|
prefixLength = 128
|
|
}
|
|
var address = in6_addr()
|
|
let converted = addressPart.withCString { inet_pton(AF_INET6, $0, &address) }
|
|
guard converted == 1 else {
|
|
var probe = in_addr()
|
|
if addressPart.withCString({ inet_pton(AF_INET, $0, &probe) }) == 1 {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
// 按 16-bit 组掩码到前缀长度。
|
|
var bytes = withUnsafeBytes(of: address) { Array($0) }
|
|
for index in 0..<16 {
|
|
let bitStart = index * 8
|
|
if prefixLength >= bitStart + 8 { continue }
|
|
if prefixLength <= bitStart {
|
|
bytes[index] = 0
|
|
} else {
|
|
let keep = prefixLength - bitStart
|
|
let mask = UInt8.max << (8 - keep)
|
|
bytes[index] &= mask
|
|
}
|
|
}
|
|
var networkAddress = bytes.withUnsafeBytes { rawPointer in
|
|
rawPointer.load(as: in6_addr.self)
|
|
}
|
|
var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
|
|
guard inet_ntop(AF_INET6, &networkAddress, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
guard let address = Self.string(from: buffer) else {
|
|
throw RuleMatchError.invalidCIDR(raw)
|
|
}
|
|
return "\(address)/\(prefixLength)"
|
|
}
|
|
|
|
private static func string(from buffer: [CChar]) -> String? {
|
|
let bytes = buffer.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) }
|
|
return String(bytes: bytes, encoding: .utf8)
|
|
}
|
|
}
|
|
|
|
enum RuleMatchError: Error, Equatable, Sendable {
|
|
case emptyDomain
|
|
case invalidDomain(String)
|
|
case invalidCIDR(String)
|
|
}
|
|
|
|
// MARK: - 来源身份
|
|
|
|
/// 内置规则来源身份。
|
|
enum RuleSourceKind: String, CaseIterable, Equatable, Hashable, Sendable {
|
|
case geolocationCN = "geolocation-cn"
|
|
case chinaIPv4 = "china-ipv4"
|
|
case gfwlist = "gfwlist"
|
|
case custom = "custom"
|
|
}
|
|
|
|
/// 内置快照的来源类别、上游版本与展示标签。
|
|
struct RuleSourceIdentity: Equatable, Hashable, Sendable {
|
|
let kind: RuleSourceKind
|
|
let upstreamVersion: String
|
|
let label: String
|
|
|
|
init(kind: RuleSourceKind, upstreamVersion: String, label: String) {
|
|
self.kind = kind
|
|
self.upstreamVersion = upstreamVersion
|
|
self.label = label
|
|
}
|
|
|
|
var id: String { kind.rawValue }
|
|
}
|
|
|
|
// MARK: - 规则
|
|
|
|
/// 一条来源无关的代理规则。
|
|
struct ProxyRule: Equatable, Hashable, Sendable {
|
|
let action: RuleAction
|
|
let match: RuleMatch
|
|
|
|
init(
|
|
action: RuleAction,
|
|
match: RuleMatch
|
|
) {
|
|
self.action = action
|
|
self.match = match
|
|
}
|
|
|
|
/// Stable historical token, independent of Swift's debug representation.
|
|
var contentToken: String {
|
|
let kind: String
|
|
let value: String
|
|
switch match.pattern {
|
|
case .domainExact(let raw): (kind, value) = ("domainExact", raw)
|
|
case .domainSuffix(let raw): (kind, value) = ("domainSuffix", raw)
|
|
case .ipv4CIDR(let raw): (kind, value) = ("ipv4CIDR", raw)
|
|
case .ipv6CIDR(let raw): (kind, value) = ("ipv6CIDR", raw)
|
|
}
|
|
return "\(action.rawValue)|\(kind)(\(String(reflecting: value)))"
|
|
}
|
|
|
|
/// sslocal ACL 单行表示(ASCII)。域名后缀 `||host`、完整域名 `|host`、
|
|
/// CIDR 原样写入。
|
|
var aclLine: String {
|
|
switch match.pattern {
|
|
case .domainExact(let domain):
|
|
return "|\(domain)"
|
|
case .domainSuffix(let domain):
|
|
return "||\(domain)"
|
|
case .ipv4CIDR(let cidr), .ipv6CIDR(let cidr):
|
|
return cidr
|
|
}
|
|
}
|
|
}
|