mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-08 12:44:51 +00:00
372 lines
14 KiB
Swift
372 lines
14 KiB
Swift
import Darwin
|
||
import Foundation
|
||
|
||
// MARK: - CIDR 解析
|
||
|
||
enum AddressFamily: Equatable, Sendable {
|
||
case ipv4
|
||
case ipv6
|
||
}
|
||
|
||
struct ParsedCIDR: Equatable, Sendable {
|
||
let family: AddressFamily
|
||
let bytes: [UInt8]
|
||
let prefixLength: Int
|
||
}
|
||
|
||
// MARK: - 拒绝原因
|
||
|
||
/// Blocking custom-rule validation errors. Shadowing is a relationship, not an error.
|
||
enum CustomRuleRejection: Equatable, Sendable {
|
||
/// 代理动作覆盖固定本地范围(回环/私有/链路本地/localhost/*.local/无点主机名)。
|
||
/// 应用固定的本地绕过规则始终优先于用户代理规则;冲突规则不会生效。
|
||
case conflictsWithFixedLocalScope
|
||
/// 同动作+同匹配条件重复。
|
||
case duplicate
|
||
|
||
var label: String {
|
||
switch self {
|
||
case .conflictsWithFixedLocalScope:
|
||
return "与固定本地范围冲突"
|
||
case .duplicate:
|
||
return "重复规则"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 被拒绝的自定义规则及其可解释原因。
|
||
struct RejectedCustomRule: Equatable, Sendable {
|
||
let rule: CustomRule
|
||
let reason: CustomRuleRejection
|
||
/// 可解释说明:覆盖/遮蔽方的匹配条件描述,不含无关来源内容。
|
||
let explanation: String
|
||
}
|
||
|
||
/// Valid candidates, blocking errors, and nonblocking coverage explanations.
|
||
struct CustomRuleValidationResult: Equatable, Sendable {
|
||
let accepted: [ProxyRule]
|
||
let rejected: [RejectedCustomRule]
|
||
var relationships: [RuleRelationship] = []
|
||
}
|
||
|
||
// MARK: - 覆盖判定
|
||
|
||
/// 匹配条件的覆盖/相交判定:用于固定本地冲突与 ACL 优先级遮蔽检查。
|
||
enum RuleCoverage {
|
||
// MARK: 域名
|
||
|
||
/// `covering` 的域名目标集合是否包含 `covered` 的域名目标集合。
|
||
static func domainCovers(_ covering: RuleMatch, _ covered: RuleMatch) -> Bool {
|
||
guard let coveringDomain = domainValue(covering), let coveredDomain = domainValue(covered)
|
||
else { return false }
|
||
let coveringIsSuffix = isDomainSuffix(covering)
|
||
let coveredIsSuffix = isDomainSuffix(covered)
|
||
switch (coveringIsSuffix, coveredIsSuffix) {
|
||
case (true, _):
|
||
// 后缀覆盖:目标域等于后缀,或是后缀的子域。
|
||
return coveredDomain == coveringDomain
|
||
|| coveredDomain.hasSuffix("." + coveringDomain)
|
||
case (false, true):
|
||
// 精确规则无法覆盖后缀规则(后缀还含子域)。
|
||
return false
|
||
case (false, false):
|
||
return coveredDomain == coveringDomain
|
||
}
|
||
}
|
||
|
||
/// 域名目标集合是否相交(用于固定本地冲突)。
|
||
static func domainIntersects(_ lhs: RuleMatch, _ rhs: RuleMatch) -> Bool {
|
||
// 任一覆盖另一即相交;后缀的点分隔边界确保互不覆盖的两个后缀不相交。
|
||
domainCovers(lhs, rhs) || domainCovers(rhs, lhs)
|
||
}
|
||
|
||
/// 是否命中固定本地主机模式:`localhost`、`*.local`、无点主机名。
|
||
static func matchesFixedLocalHost(_ match: RuleMatch) -> Bool {
|
||
guard let domain = domainValue(match) else { return false }
|
||
let isSuffix = isDomainSuffix(match)
|
||
// 无点主机名(含 `local` 本身)。
|
||
if !domain.contains(".") {
|
||
return true
|
||
}
|
||
// `*.local` / `local` 后缀。
|
||
if isSuffix {
|
||
return domain == "local" || domain.hasSuffix(".local")
|
||
}
|
||
return domain == "localhost" || domain.hasSuffix(".local") || domain.hasSuffix(".localhost")
|
||
}
|
||
|
||
/// 代理动作的域名规则是否与固定本地主机范围冲突。
|
||
static func domainConflictsWithFixedLocal(_ match: RuleMatch) -> Bool {
|
||
// 固定本地 ACL 主机规则:||localhost、||local、^[^.]+$。
|
||
let fixedLocalHostMatches: [RuleMatch] = [
|
||
.localhostSuffix,
|
||
.localSuffix,
|
||
]
|
||
if matchesFixedLocalHost(match) { return true }
|
||
for fixed in fixedLocalHostMatches where domainIntersects(match, fixed) {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// MARK: IP
|
||
|
||
/// `covering` 的 IP 目标集合是否包含 `covered` 的 IP 目标集合(含 mapped 跨家族匹配)。
|
||
static func ipCovers(_ covering: RuleMatch, _ covered: RuleMatch) -> Bool {
|
||
guard let outer = matchingCIDR(covering), let inner = matchingCIDR(covered)
|
||
else { return false }
|
||
return outer.prefixLength <= inner.prefixLength
|
||
&& networkContains(outer: outer, inner: inner)
|
||
}
|
||
|
||
/// IP 目标集合是否相交。
|
||
static func ipIntersects(_ lhs: RuleMatch, _ rhs: RuleMatch) -> Bool {
|
||
guard let left = matchingCIDR(lhs), let right = matchingCIDR(rhs)
|
||
else { return false }
|
||
return networkContains(outer: left, inner: right) || networkContains(outer: right, inner: left)
|
||
}
|
||
|
||
/// 代理动作的 IP 规则是否与固定本地 IP 范围冲突。
|
||
static func ipConflictsWithFixedLocal(_ match: RuleMatch) -> Bool {
|
||
for range in FixedLocalProxyRanges.ipRanges {
|
||
if let fixed = parseFixedLocalCIDR(range), ipIntersects(match, fixed) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
static func winningAction(for match: RuleMatch) -> RuleAction {
|
||
switch match.pattern {
|
||
case .domainExact, .domainSuffix: .proxy
|
||
case .ipv4CIDR, .ipv6CIDR: .direct
|
||
}
|
||
}
|
||
|
||
static func intersects(_ lhs: RuleMatch, _ rhs: RuleMatch) -> Bool {
|
||
domainIntersects(lhs, rhs) || ipIntersects(lhs, rhs)
|
||
}
|
||
|
||
static func fullyCovered(_ target: RuleMatch, by matches: [RuleMatch]) -> Bool {
|
||
if matches.contains(where: { domainCovers($0, target) }) { return true }
|
||
guard let range = matchingCIDR(target) else { return false }
|
||
return fullyCovered(range, by: matches.compactMap { matchingCIDR($0) })
|
||
}
|
||
|
||
/// IPv4 and its mapped IPv6 image match the same addresses in sslocal v1.26.0.
|
||
/// Identity keeps the original family; coverage uses the common IPv6 space.
|
||
private static func matchingCIDR(_ match: RuleMatch) -> ParsedCIDR? {
|
||
guard let range = parseCIDR(match) else { return nil }
|
||
guard range.family == .ipv4 else { return range }
|
||
return ParsedCIDR(
|
||
family: .ipv6, bytes: Array(repeating: 0, count: 10) + [255, 255] + range.bytes,
|
||
prefixLength: 96 + range.prefixLength)
|
||
}
|
||
|
||
/// Several disjoint narrower CIDRs can together cover a whole range.
|
||
private static func fullyCovered(_ target: ParsedCIDR, by ranges: [ParsedCIDR]) -> Bool {
|
||
let overlapping = ranges.filter {
|
||
networkContains(outer: $0, inner: target) || networkContains(outer: target, inner: $0)
|
||
}
|
||
if overlapping.contains(where: {
|
||
$0.prefixLength <= target.prefixLength && networkContains(outer: $0, inner: target)
|
||
}) {
|
||
return true
|
||
}
|
||
guard !overlapping.isEmpty, target.prefixLength < 128 else { return false }
|
||
let nextPrefix = target.prefixLength + 1
|
||
var upperBytes = target.bytes
|
||
upperBytes[target.prefixLength / 8] |= UInt8(1) << (7 - target.prefixLength % 8)
|
||
let lower = ParsedCIDR(family: .ipv6, bytes: target.bytes, prefixLength: nextPrefix)
|
||
let upper = ParsedCIDR(family: .ipv6, bytes: upperBytes, prefixLength: nextPrefix)
|
||
return fullyCovered(lower, by: overlapping) && fullyCovered(upper, by: overlapping)
|
||
}
|
||
|
||
// MARK: 解析
|
||
|
||
static func parseCIDR(_ match: RuleMatch) -> ParsedCIDR? {
|
||
switch match.pattern {
|
||
case .ipv4CIDR(let cidr):
|
||
return parseCIDRString(cidr, family: .ipv4, maxPrefix: 32)
|
||
case .ipv6CIDR(let cidr):
|
||
return parseCIDRString(cidr, family: .ipv6, maxPrefix: 128)
|
||
case .domainExact, .domainSuffix:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static func parseFixedLocalCIDR(_ raw: String) -> RuleMatch? {
|
||
if raw.contains(":") {
|
||
return try? RuleMatch(ipv6CIDR: raw)
|
||
}
|
||
return try? RuleMatch(ipv4CIDR: raw)
|
||
}
|
||
|
||
private static func parseCIDRString(
|
||
_ cidr: String, family: AddressFamily, maxPrefix: Int
|
||
) -> ParsedCIDR? {
|
||
let parts = cidr.split(separator: "/", maxSplits: 1)
|
||
guard !parts.isEmpty else { return nil }
|
||
let addressPart = String(parts[0])
|
||
let prefixLength: Int
|
||
if parts.count == 2 {
|
||
guard let parsed = Int(parts[1]), (0...maxPrefix).contains(parsed) else { return nil }
|
||
prefixLength = parsed
|
||
} else {
|
||
prefixLength = maxPrefix
|
||
}
|
||
switch family {
|
||
case .ipv4:
|
||
var address = in_addr()
|
||
guard addressPart.withCString({ inet_pton(AF_INET, $0, &address) }) == 1 else { return nil }
|
||
let host = UInt32(bigEndian: address.s_addr)
|
||
let mask: UInt32 = prefixLength == 0 ? 0 : UInt32.max << (32 - prefixLength)
|
||
let network = host & mask
|
||
let bytes = withUnsafeBytes(of: network.bigEndian) { Array($0) }
|
||
return ParsedCIDR(family: .ipv4, bytes: bytes, prefixLength: prefixLength)
|
||
case .ipv6:
|
||
var address = in6_addr()
|
||
guard addressPart.withCString({ inet_pton(AF_INET6, $0, &address) }) == 1 else { return nil }
|
||
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
|
||
bytes[index] &= UInt8.max << (8 - keep)
|
||
}
|
||
}
|
||
return ParsedCIDR(family: .ipv6, bytes: bytes, prefixLength: prefixLength)
|
||
}
|
||
}
|
||
|
||
private static func networkContains(outer: ParsedCIDR, inner: ParsedCIDR) -> Bool {
|
||
let fullBytes = outer.prefixLength / 8
|
||
let remainderBits = outer.prefixLength % 8
|
||
guard outer.bytes.count == inner.bytes.count else { return false }
|
||
for index in 0..<fullBytes where outer.bytes[index] != inner.bytes[index] {
|
||
return false
|
||
}
|
||
if remainderBits > 0 {
|
||
let mask = UInt8.max << (8 - remainderBits)
|
||
guard (outer.bytes[fullBytes] & mask) == (inner.bytes[fullBytes] & mask) else {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
private static func domainValue(_ match: RuleMatch) -> String? {
|
||
switch match.pattern {
|
||
case .domainExact(let domain), .domainSuffix(let domain):
|
||
return domain
|
||
case .ipv4CIDR, .ipv6CIDR:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static func isDomainSuffix(_ match: RuleMatch) -> Bool {
|
||
if case .domainSuffix = match.pattern { return true }
|
||
return false
|
||
}
|
||
}
|
||
|
||
// MARK: - 校验器
|
||
|
||
/// Reject only duplicate custom identities and fixed-local conflicts. Analyze
|
||
/// coverage against the complete valid set using the runtime's expressed actions.
|
||
/// Accepted candidates retain saved intent; relationships never remove entries.
|
||
enum CustomRuleValidator {
|
||
static func validate(
|
||
custom: [CustomRule],
|
||
builtIn: [ProxyRule] = [],
|
||
defaultAction: RuleDefaultAction
|
||
) -> CustomRuleValidationResult {
|
||
let validated = hardValidation(custom: custom)
|
||
let candidates = builtIn + validated.accepted
|
||
let expressed = candidates.filter { defaultAction.expresses($0.action) }
|
||
let analysis = RuleAnalysis(rules: expressed, subjects: validated.accepted)
|
||
return CustomRuleValidationResult(
|
||
accepted: validated.accepted, rejected: validated.rejected,
|
||
relationships: analysis.relationships)
|
||
}
|
||
|
||
/// Persistence and ACL compilation need blocking validation, not browsing explanations.
|
||
static func hardValidation(custom: [CustomRule]) -> CustomRuleValidationResult {
|
||
var accepted: [ProxyRule] = []
|
||
var rejected: [RejectedCustomRule] = []
|
||
let counts = Dictionary(grouping: custom, by: \.contentToken).mapValues(\.count)
|
||
for rule in custom.sorted(by: {
|
||
($0.contentToken, $0.id.uuidString) < ($1.contentToken, $1.id.uuidString)
|
||
}) {
|
||
if counts[rule.contentToken, default: 0] > 1 {
|
||
rejected.append(
|
||
RejectedCustomRule(
|
||
rule: rule, reason: .duplicate, explanation: "同动作同匹配条件的规则已存在"))
|
||
} else if let rejection = fixedLocalRejection(for: rule) {
|
||
rejected.append(rejection)
|
||
} else {
|
||
accepted.append(rule.proxyRule)
|
||
}
|
||
}
|
||
return CustomRuleValidationResult(accepted: accepted, rejected: rejected)
|
||
}
|
||
|
||
/// 单条规则的固定本地冲突检查(保存与编译共用)。
|
||
static func fixedLocalRejection(for rule: CustomRule) -> RejectedCustomRule? {
|
||
guard rule.action == .proxy else { return nil }
|
||
let conflicts: Bool
|
||
switch rule.identity.match.pattern {
|
||
case .domainExact, .domainSuffix:
|
||
conflicts = RuleCoverage.domainConflictsWithFixedLocal(rule.identity.match)
|
||
case .ipv4CIDR, .ipv6CIDR:
|
||
conflicts = RuleCoverage.ipConflictsWithFixedLocal(rule.identity.match)
|
||
}
|
||
guard conflicts else { return nil }
|
||
return RejectedCustomRule(
|
||
rule: rule,
|
||
reason: .conflictsWithFixedLocalScope,
|
||
explanation: "应用固定的本地绕过规则优先;这些本地目标不会经代理")
|
||
}
|
||
|
||
}
|
||
|
||
/// Pure runtime projection of the selected preset’s enabled candidates.
|
||
enum RuleRuntimeCompiler {
|
||
struct Input: Sendable {
|
||
let source: SslocalRuntimeDocument
|
||
let mode: ProxyMode
|
||
let defaultAction: RuleDefaultAction
|
||
let document: CustomRuleDocument?
|
||
let builtIn: [ProxyRule]
|
||
let aclURL: URL
|
||
}
|
||
|
||
static func compile(_ input: Input) -> SslocalRuntimeDocument {
|
||
let source = input.source
|
||
let mode = input.mode
|
||
let defaultAction = input.defaultAction
|
||
let document = input.document
|
||
let builtIn = input.builtIn
|
||
let aclURL = input.aclURL
|
||
switch mode {
|
||
case .direct: return source.replacingACL(.direct(at: aclURL))
|
||
case .global: return source.replacingACL(.global(at: aclURL))
|
||
case .rule:
|
||
let document = document ?? CustomRuleDocument(rules: [])
|
||
let custom = document.rules.filter {
|
||
$0.action != defaultAction.fallbackAction
|
||
&& !document.disabledIdentities.contains($0.identity)
|
||
}
|
||
let enabled = builtIn.filter { !document.disabledIdentities.contains($0) }
|
||
let validated = CustomRuleValidator.hardValidation(custom: custom)
|
||
let candidates = RuleAnalysis.runtimeCandidates(
|
||
enabled + validated.accepted, defaultAction: defaultAction)
|
||
return source.replacingACL(
|
||
.rule(at: aclURL, defaultAction: defaultAction, rules: candidates))
|
||
}
|
||
}
|
||
}
|