Files

182 lines
6.5 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CryptoKit
import Foundation
/// Fixed local targets bypass the system proxy. Their IP ranges and host rules
/// also enter the sslocal ACL for requests reaching either inbound.
enum FixedLocalProxyRanges {
static let ipRanges = [
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"::1/128",
"fe80::/10",
"fc00::/7",
]
static let systemProxyHostExceptions = ["localhost", "*.local"]
static var systemProxyExceptions: [String] {
ipRanges + systemProxyHostExceptions
}
static func systemProxyExceptions(including values: [String]?) -> [String] {
var result: [String] = []
var seen = Set<String>()
for value in systemProxyExceptions + (values ?? []) {
let identity = value.lowercased()
guard seen.insert(identity).inserted else { continue }
result.append(value)
}
return result
}
static let aclHostRules = ["||localhost", "||local", "^[^.]+$"]
static var aclBypassRules: [String] {
ipRanges + aclHostRules
}
}
/// GUI 生成的 ACL 与变体身份。正文仅用于准备 ACL 文件;私有意图编码
/// 只包含 path/summary/sha256,上游配置只包含稳定 ACL 路径。
/// 身份相等性忽略正文,避免把大规则文本带入比较。
struct ProxyACLDocument: Codable, Equatable, Sendable {
let path: String
let summary: String
let content: String
let sha256: String
enum CodingKeys: String, CodingKey {
case path, summary, sha256
}
init(path: String, summary: String, content: String) {
self.init(path: path, summary: summary, content: content, sha256: Self.digest(content))
}
init(path: String, summary: String, content: String, sha256: String) {
self.path = path
self.summary = summary
self.content = content
self.sha256 = sha256
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
path = try container.decode(String.self, forKey: .path)
summary = try container.decode(String.self, forKey: .summary)
sha256 = try container.decode(String.self, forKey: .sha256)
content = ""
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(path, forKey: .path)
try container.encode(summary, forKey: .summary)
try container.encode(sha256, forKey: .sha256)
}
static func == (lhs: ProxyACLDocument, rhs: ProxyACLDocument) -> Bool {
lhs.path == rhs.path && lhs.summary == rhs.summary && lhs.sha256 == rhs.sha256
}
/// Direct mode deliberately has no `proxy_list`: `bypass_all` supplies the
/// default, while the explicit fixed-local entries remain a safety policy
/// shared by SOCKS and HTTP and reusable by later routing modes.
static func direct(at fileURL: URL) -> ProxyACLDocument {
makeDocument(
at: fileURL, header: "[bypass_all]", summary: "direct")
}
/// 全局模式:公网目标默认走 Shadowsocks 代理,本地网络目标固定
/// 直连。ACL 只含固定本地安全绕过,不混入中国列表、GFWList 或自定义规则;
/// SOCKS、HTTP 入站与局域网共享客户端共用此策略。IPv6 系统例外不构成已验证
/// 的绕过保证,本 ACL 才是路由兜底。
static func global(at fileURL: URL) -> ProxyACLDocument {
makeDocument(
at: fileURL, header: "[proxy_all]", summary: "global")
}
/// 规则模式:默认动作决定 ACL 骨架,调用方按默认动作
/// 传入对应来源的候选(两种默认动作不把全部内置来源无条件并集)。
///
/// - 「未匹配时代理」:`proxy_all` + 中国域名/CIDR 直连候选进 `bypass_list`。
/// - 「未匹配时直连」:`bypass_all` + GFWList 可生效代理规则进 `proxy_list`;
/// 未匹配目标与无可用规则命中的 IP 字面目标直连。
///
/// 固定本地绕过优先;CIDR 判定可能触发本地 DNS 查询。
static func rule(
at fileURL: URL,
defaultAction: RuleDefaultAction,
rules: [ProxyRule]
) -> ProxyACLDocument {
// Select expressed actions before any coverage analysis. Keep fixed policy
// first; equivalent source entries and array order never change ACL bytes.
let directLines = Array(Set(rules.filter { $0.action == .direct }.map(\.aclLine)))
.filter { !FixedLocalProxyRanges.aclBypassRules.contains($0) }.sorted()
let proxyLines = Array(Set(rules.filter { $0.action == .proxy }.map(\.aclLine))).sorted()
switch defaultAction {
case .proxyWhenUnmatched:
// proxy_all + 直连候选进 bypass_list(中国域名 / IPv4 CIDR)。
let bypass =
FixedLocalProxyRanges.aclBypassRules
+ directLines
return makeDocument(
at: fileURL, header: "[proxy_all]", summary: "rule-proxy-default",
bypassRules: bypass, proxyRules: [])
case .directWhenUnmatched:
// bypass_all + GFWList 可生效规则:代理候选进 proxy_list,未遮蔽例外
// 进 bypass_list。两种默认动作不无条件并集中国来源。
let bypass =
FixedLocalProxyRanges.aclBypassRules
+ directLines
let proxy = proxyLines
return makeDocument(
at: fileURL, header: "[bypass_all]", summary: "rule-direct-default",
bypassRules: bypass, proxyRules: proxy)
}
}
private static func makeDocument(
at fileURL: URL, header: String, summary: String,
bypassRules: [String] = FixedLocalProxyRanges.aclBypassRules,
proxyRules: [String] = []
) -> ProxyACLDocument {
var lines = [header, "[bypass_list]"] + bypassRules
if !proxyRules.isEmpty {
lines.append("[proxy_list]")
lines.append(contentsOf: proxyRules)
}
return ProxyACLDocument(
path: fileURL.standardizedFileURL.path,
summary: summary,
content: lines.joined(separator: "\n") + "\n")
}
var isWellFormed: Bool {
guard
path.hasPrefix("/"),
!summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
sha256.count == 64,
sha256.allSatisfy({ $0.isHexDigit })
else { return false }
// 生成侧携带 content 时自证摘要;解码侧无 content,按身份形状收下
// (ADR-0011:Agent 不读 sidecar,摘要一致性由 GUI 写入序保证)。
if content.isEmpty { return true }
return content.utf8.allSatisfy { $0 < 128 }
&& sha256 == Self.digest(content)
}
static func digest(_ content: String) -> String {
digest(Data(content.utf8))
}
static func digest(_ data: Data) -> String {
SHA256.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
}
}