diff --git a/apple/ClashClient/Sources/ConfigUI/ConfigurationEditorViews.swift b/apple/ClashClient/Sources/ConfigUI/ConfigurationEditorViews.swift index 26d1a17..45c01f4 100644 --- a/apple/ClashClient/Sources/ConfigUI/ConfigurationEditorViews.swift +++ b/apple/ClashClient/Sources/ConfigUI/ConfigurationEditorViews.swift @@ -4101,6 +4101,7 @@ enum ConfigurationEditorCopy { case .general: "General Configuration (Configuration Chapter)" case .dns: "DNS" case .sniffer: "Domain Sniffing (Configuration Chapter)" + case .mitm: "MITM Interception (Configuration Chapter)" case .inbound: "Inbounds (Configuration Chapter)" case .proxies: "Proxies (Configuration Chapter)" case .proxyProviders: "Proxy Providers (Configuration Chapter)" @@ -4131,6 +4132,7 @@ enum ConfigurationEditorCopy { switch section { case .dns: "dns" case .sniffer: "sniffer" + case .mitm: "mitm" default: section.rawValue } } @@ -4142,10 +4144,13 @@ enum ConfigurationEditorCopy { - static func value(of row: ConfigurationOutlineRow, snifferEnabled: Bool? = nil) -> ClashDisplayText? { + static func value(of row: ConfigurationOutlineRow, snifferEnabled: Bool? = nil, + mitmEnabled: Bool? = nil) -> ClashDisplayText? { switch row.section { case .sniffer: return snifferEnabled.map { .copy($0 ? "On" : "Off") } + case .mitm: + return mitmEnabled.map { .copy($0 ? "On" : "Off") } case .overrideScript: return nil default: @@ -4170,6 +4175,14 @@ enum ConfigurationEditorCopy { } } + @MainActor static func mitmEnabled(in session: ConfigurationEditorSession) -> Bool? { + switch session.settingsValue(at: ["mitm", "enable"]) { + case .scalar(let token)?: return token == "true" ? true : token == "false" ? false : nil + case .string(let text)?: return text == "true" ? true : text == "false" ? false : nil + default: return nil + } + } + static func summary(of value: OrderedJSON) -> String { switch value { diff --git a/apple/ClashClient/Sources/ConfigUI/ConfigurationOnePage.swift b/apple/ClashClient/Sources/ConfigUI/ConfigurationOnePage.swift index cb94206..7e719d5 100644 --- a/apple/ClashClient/Sources/ConfigUI/ConfigurationOnePage.swift +++ b/apple/ClashClient/Sources/ConfigUI/ConfigurationOnePage.swift @@ -467,7 +467,9 @@ struct ConfigurationPageSections: View { valueRow(ConfigurationEditorCopy.title(of: row.section), value: row.section == .overrideScript ? overrideScriptName : ConfigurationEditorCopy.value(of: row, snifferEnabled: row.section == .sniffer - ? ConfigurationEditorCopy.snifferEnabled(in: session) : nil)) + ? ConfigurationEditorCopy.snifferEnabled(in: session) : nil, + mitmEnabled: row.section == .mitm + ? ConfigurationEditorCopy.mitmEnabled(in: session) : nil)) } .accessibilityIdentifier("configuration-editor.section.\(row.section.rawValue)") } @@ -915,7 +917,7 @@ enum ConfigurationSectionRoute: Equatable { - case .general, .dns, .sniffer, .inbound, .ntp, .experimental: return .settings([]) + case .general, .dns, .sniffer, .mitm, .inbound, .ntp, .experimental: return .settings([]) case .tunnels: return .objectList(["tunnels"]) case .customRules: return .customRules case .overrideScript: return .overrideScript diff --git a/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationFieldValidation.swift b/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationFieldValidation.swift index 638a296..0b0d644 100644 --- a/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationFieldValidation.swift +++ b/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationFieldValidation.swift @@ -67,10 +67,11 @@ public enum ConfigurationFieldValidation { static let trieDomainPaths: Set = [ "sniffer.skip-domain", "sniffer.force-domain", "dns.fake-ip-filter", "dns.fallback-filter.domain", + "mitm.skip-domain", ] - static let ipMatcherPaths: Set = ["sniffer.skip-src-address", "sniffer.skip-dst-address"] + static let ipMatcherPaths: Set = ["sniffer.skip-src-address", "sniffer.skip-dst-address", "mitm.skip-dst-address"] diff --git a/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationGoverningSwitch.swift b/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationGoverningSwitch.swift index 4811b73..8422ba8 100644 --- a/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationGoverningSwitch.swift +++ b/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationGoverningSwitch.swift @@ -67,7 +67,7 @@ public extension ConfigurationFieldCatalog { - static let auditedSwitchPaths: Set = ["sniffer.enable", "ntp.enable", "tuic-server.enable", "iptables.enable"] + static let auditedSwitchPaths: Set = ["sniffer.enable", "mitm.enable", "ntp.enable", "tuic-server.enable", "iptables.enable"] static let auditedSwitchSuffixes = [ ".smux.enabled", ".smux.brutal-opts.enabled", ".ech-opts.enable", ".realm-opts.enable", ".ss-opts.enabled", ".transport-layer-padding.enabled", ".mux-option.brutal.enabled", ".shadow-tls.enable", ".res-tls.enable", diff --git a/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationOutline.swift b/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationOutline.swift index a44a8fb..8aa3cfb 100644 --- a/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationOutline.swift +++ b/apple/ClashClientKit/Sources/ClashClientKit/ConfigurationOutline.swift @@ -7,7 +7,7 @@ import Foundation public enum ConfigurationOutlineSection: String, CaseIterable, Sendable { - case general, dns, sniffer, inbound, proxies, proxyProviders, proxyGroups, rules, ruleProviders, subRules + case general, dns, sniffer, mitm, inbound, proxies, proxyProviders, proxyGroups, rules, ruleProviders, subRules case tunnels, ntp, experimental, unknown @@ -87,8 +87,9 @@ public enum ConfigurationOutline { ("dns", .dns), ("hosts", .dns), ("sniffer", .sniffer), - - + + ("mitm", .mitm), + ("port", .inbound), ("socks-port", .inbound), ("mixed-port", .inbound), ("redir-port", .inbound), ("tproxy-port", .inbound), ("inbound-tfo", .inbound), ("inbound-mptcp", .inbound), ("tun", .inbound), ("listeners", .inbound), ("ss-config", .inbound), ("vmess-config", .inbound), ("tuic-server", .inbound), @@ -119,7 +120,7 @@ public enum ConfigurationOutline { public static func sections(for form: ConfigurationOutlineForm) -> [ConfigurationOutlineSection] { - [.general, .dns, .sniffer, .inbound, .proxies, .proxyProviders, .proxyGroups, .rules, + [.general, .dns, .sniffer, .mitm, .inbound, .proxies, .proxyProviders, .proxyGroups, .rules, .ruleProviders, .subRules, .tunnels, .ntp, .experimental, .unknown, .customRules, .overrideScript] } @@ -127,7 +128,7 @@ public enum ConfigurationOutline { public static func showsDefaults(_ section: ConfigurationOutlineSection) -> Bool { - [.general, .dns, .sniffer, .inbound, .ntp, .experimental].contains(section) + [.general, .dns, .sniffer, .mitm, .inbound, .ntp, .experimental].contains(section) } @@ -223,6 +224,10 @@ public enum ConfigurationOutline { let sniffer = value("sniffer") let protocols = field(sniffer, "sniff") ?? field(sniffer, "sniffing") return .init(section: section, count: size(protocols), enabled: isTrue(field(sniffer, "enable"))) + case .mitm: + let mitm = value("mitm") + let policies = field(mitm, "rewrite") + return .init(section: section, count: size(policies), enabled: isTrue(field(mitm, "enable"))) case .unknown: return .init(section: section, count: Set(pairs.map(\.key).filter { sectionIndex[$0] == nil }).count) } diff --git a/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedList.swift b/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedList.swift index 7146368..777917e 100644 --- a/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedList.swift +++ b/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedList.swift @@ -68,6 +68,12 @@ public enum UpstreamListDefault { "sniffer.skip-dst-address": PinnedDefault([]), "sniffer.port-whitelist": PinnedDefault([]), "sniffer.sniffing": PinnedDefault([]), + "mitm.hosts": PinnedDefault([]), + "mitm.skip-domain": PinnedDefault([]), + "mitm.skip-dst-address": PinnedDefault([]), + "mitm.https-ports": PinnedDefault([]), + "mitm.http-ports": PinnedDefault([]), + "mitm.rewrite": PinnedDefault([]), ] public static func pinned(for keyPath: String) -> PinnedDefault<[String]> { diff --git a/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedValue.swift b/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedValue.swift index 5201759..ecdd81a 100644 --- a/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedValue.swift +++ b/apple/ClashClientUI/Sources/ClashClientUI/Components/InheritedValue.swift @@ -72,6 +72,7 @@ public enum UpstreamBoolDefault { "disable-keep-alive": .init(false), "ntp.enable": .init(false), "sniffer.enable": .init(false), + "mitm.enable": .init(false), "sniffer.force-dns-mapping": .init(true), "sniffer.parse-pure-ip": .init(true), "sniffer.override-destination": .init(true), diff --git a/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/a-mitm.json b/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/a-mitm.json new file mode 100644 index 0000000..24351ef --- /dev/null +++ b/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/a-mitm.json @@ -0,0 +1,109 @@ +{ + "mitm": { + "title": {"en": "MITM Interception", "zh-Hans": "MITM 拦截", "zh-Hant": "MITM 攔截"}, + "summary": {"en": "Terminates TLS on hosts you control, rewrites requests, and forwards them through the normal rule path.", + "zh-Hans": "对自己掌控的域名终结 TLS,改写请求后仍走正常的规则链路。", + "zh-Hant": "對自己掌控的網域終結 TLS,改寫請求後仍走正常的規則鏈路。"} + }, + "mitm.enable": { + "title": {"en": "MITM Interception", "zh-Hans": "启用 MITM 拦截", "zh-Hant": "啟用 MITM 攔截"}, + "summary": {"en": "Gated connections get a locally generated certificate. Install and trust the CA from the app before enabling.", + "zh-Hans": "被拦截的连接会收到本地生成的证书。启用前请先在应用内安装并信任 CA。", + "zh-Hant": "被攔截的連線會收到本地產生的憑證。啟用前請先在 App 內安裝並信任 CA。"} + }, + "mitm.store-dir": { + "title": {"en": "CA Store Directory", "zh-Hans": "CA 存储目录", "zh-Hant": "CA 儲存目錄"}, + "summary": {"en": "Persists the generated CA so restarts keep the same trust profile. Empty regenerates on every launch.", + "zh-Hans": "持久化生成的 CA,重启后保持同一个信任配置。留空则每次启动重新生成。", + "zh-Hant": "持久化產生的 CA,重啟後保持同一個信任設定。留空則每次啟動重新產生。"} + }, + "mitm.hosts": { + "title": {"en": "Intercept Hosts", "zh-Hans": "拦截的域名", "zh-Hant": "攔截的網域"}, + "summary": {"en": "Domains whose TLS is terminated. +.example.com covers every name under it.", + "zh-Hans": "终结这些域名的 TLS。+.example.com 覆盖其下所有子域。", + "zh-Hant": "終結這些網域的 TLS。+.example.com 涵蓋其下所有子網域。"}, + "unset": {"en": "None", "zh-Hans": "无", "zh-Hant": "無"}, + "example": "+.example.com" + }, + "mitm.skip-domain": { + "title": {"en": "Never Intercept · Domain", "zh-Hans": "强制不拦截 · 域名", "zh-Hant": "強制不攔截 · 網域"}, + "summary": {"en": "These domains are never intercepted, even inside the host gates.", + "zh-Hans": "这些域名永远不拦截,即使命中上方的域名列表。", + "zh-Hant": "這些網域一律不攔截,即使命中上方的網域清單。"}, + "unset": {"en": "None", "zh-Hans": "无", "zh-Hant": "無"}, + "example": "auth.example.com" + }, + "mitm.skip-dst-address": { + "title": {"en": "Never Intercept · Destination Range", "zh-Hans": "强制不拦截 · 目标网段", "zh-Hant": "強制不攔截 · 目標網段"}, + "summary": {"en": "Connections to these ranges are never intercepted.", + "zh-Hans": "发往这些网段的连接不拦截。", + "zh-Hant": "發往這些網段的連線不攔截。"}, + "unset": {"en": "None", "zh-Hans": "无", "zh-Hant": "無"}, + "example": "10.0.0.0/8" + }, + "mitm.https-ports": { + "title": {"en": "HTTPS Ports", "zh-Hans": "HTTPS 端口", "zh-Hant": "HTTPS 連接埠"}, + "summary": {"en": "Destination ports intercepted as HTTPS. Defaults to 443.", + "zh-Hans": "按 HTTPS 拦截的目标端口。默认 443。", + "zh-Hant": "按 HTTPS 攔截的目標連接埠。預設 443。"}, + "unset": {"en": "443", "zh-Hans": "443", "zh-Hant": "443"}, + "example": "443, 8443-8444" + }, + "mitm.http-ports": { + "title": {"en": "HTTP Ports", "zh-Hans": "HTTP 端口", "zh-Hant": "HTTP 連接埠"}, + "summary": {"en": "Destination ports intercepted as plain HTTP. Defaults to 80.", + "zh-Hans": "按明文 HTTP 拦截的目标端口。默认 80。", + "zh-Hant": "按明文 HTTP 攔截的目標連接埠。預設 80。"}, + "unset": {"en": "80", "zh-Hans": "80", "zh-Hant": "80"}, + "example": "80, 8080-8880" + }, + "mitm.rewrite": { + "title": {"en": "Rewrite Policies", "zh-Hans": "重写策略", "zh-Hant": "改寫策略"}, + "summary": {"en": "Ordered; the first matching policy wins.", + "zh-Hans": "按顺序匹配,首个命中的生效。", + "zh-Hant": "依序比對,首個命中的生效。"}, + "unset": {"en": "None", "zh-Hans": "无", "zh-Hant": "無"} + }, + "mitm.rewrite[].url-pattern": { + "title": {"en": "URL Pattern", "zh-Hans": "URL 匹配", "zh-Hant": "URL 對應"}, + "summary": {"en": "Matches the host and its subdomains under /api/; a bare /api/ matches any gated host.", + "zh-Hans": "匹配该主机及其子域的 /api/ 路径;仅填 /api/ 则匹配所有已拦截主机。", + "zh-Hant": "符合該主機及其子網域的 /api/ 路徑;僅填 /api/ 則符合所有已攔截主機。"} + }, + "mitm.rewrite[].redirect": { + "title": {"en": "Redirect To", "zh-Hans": "重定向到", "zh-Hant": "重新導向到"}, + "summary": {"en": "Answers 302 without dialing. A bare host keeps the original path and query.", + "zh-Hans": "直接返回 302,不建立上游连接。仅填主机名时保留原路径与查询参数。", + "zh-Hant": "直接回傳 302,不建立上游連線。僅填主機名稱時保留原路徑與查詢參數。"} + }, + "mitm.rewrite[].reject": { + "title": {"en": "Reject With", "zh-Hans": "拒绝并返回", "zh-Hant": "拒絕並回傳"}, + "summary": {"en": "Answers this 4xx status without dialing, for example 404.", + "zh-Hans": "直接返回该 4xx 状态码,不建立上游连接,例如 404。", + "zh-Hant": "直接回傳該 4xx 狀態碼,不建立上游連線,例如 404。"} + }, + "mitm.rewrite[].headers": { + "title": {"en": "Header Rules", "zh-Hans": "请求头改写", "zh-Hant": "請求標頭改寫"}, + "summary": {"en": "Applied to the request before it leaves for the upstream.", + "zh-Hans": "在请求发往上游之前执行。", + "zh-Hant": "在請求送往上游之前執行。"} + }, + "mitm.rewrite[].headers[].action": { + "title": {"en": "Action", "zh-Hans": "动作", "zh-Hant": "動作"}, + "summary": {"en": "set replaces the header, add appends a value, remove deletes it.", + "zh-Hans": "set 替换该请求头,add 追加一个值,remove 删除。", + "zh-Hant": "set 取代該標頭,add 附加一個值,remove 刪除。"} + }, + "mitm.rewrite[].headers[].name": { + "title": {"en": "Header", "zh-Hans": "请求头名称", "zh-Hant": "標頭名稱"} + }, + "mitm.rewrite[].headers[].value": { + "title": {"en": "Value", "zh-Hans": "值", "zh-Hant": "值"} + }, + "mitm.rewrite[].headers[].value-prefix": { + "title": {"en": "Value Prefix", "zh-Hans": "值前缀", "zh-Hant": "值字首"}, + "summary": {"en": "Prepended to the value the client already sends, for example “Bearer ”.", + "zh-Hans": "拼在客户端已发送的值前面,例如 “Bearer ”。", + "zh-Hant": "接在用戶端已傳送的值前面,例如 “Bearer ”。"} + } +} diff --git a/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/layouts-chapters.json b/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/layouts-chapters.json index fc200d4..7de291c 100644 --- a/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/layouts-chapters.json +++ b/apple/ClashClientUI/Sources/ClashClientUI/Resources/FieldCopy/layouts-chapters.json @@ -290,6 +290,67 @@ "*" ] } + ], + "mitm": [ + { + "id": "basic", + "title": { + "en": "Basics", + "zh-Hans": "基本", + "zh-Hant": "基本" + }, + "footer": { + "en": "Terminates TLS for the gated hosts and answers from the rewritten request. Install and trust the CA first.", + "zh-Hans": "对拦截域名终结 TLS,按重写策略应答。请先安装并信任 CA。", + "zh-Hant": "對攔截網域終結 TLS,依改寫策略應答。請先安裝並信任 CA。" + }, + "keys": [ + "enable", + "store-dir" + ] + }, + { + "id": "gates", + "title": { + "en": "Scope", + "zh-Hans": "拦截范围", + "zh-Hant": "攔截範圍" + }, + "keys": [ + "hosts", + "skip-domain", + "skip-dst-address", + "https-ports", + "http-ports" + ] + }, + { + "id": "rewrite", + "title": { + "en": "Rewrite Policies", + "zh-Hans": "重写策略", + "zh-Hant": "改寫策略" + }, + "footer": { + "en": "Ordered rules: the first match answers redirect/reject or edits headers, then the request leaves through the normal proxy.", + "zh-Hans": "按顺序匹配:首个命中的执行重定向、拒绝或改写请求头,之后请求仍走正常代理。", + "zh-Hant": "依序比對:首個命中的執行重新導向、拒絕或改寫請求標頭,之後請求仍走正常代理。" + }, + "keys": [ + "rewrite" + ] + }, + { + "id": "more", + "title": { + "en": "More Settings", + "zh-Hans": "更多设置", + "zh-Hant": "更多設定" + }, + "keys": [ + "*" + ] + } ] } } diff --git a/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationInspector.swift b/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationInspector.swift index 1365097..efe9a3d 100644 --- a/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationInspector.swift +++ b/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationInspector.swift @@ -777,7 +777,7 @@ public struct ClashMacConfigurationInspector: View { - case .general, .dns, .sniffer, .inbound, .ntp, .experimental: + case .general, .dns, .sniffer, .mitm, .inbound, .ntp, .experimental: guard let editing = actions.settingsSection else { return actions.outlineDestination(section) } let known = ConfigurationOutline.knownTopLevelKeys.filter { ConfigurationOutline.section(forTopLevelKey: $0) == section } return .page { diff --git a/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationOutlineCard.swift b/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationOutlineCard.swift index 77e1548..a998f8e 100644 --- a/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationOutlineCard.swift +++ b/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacConfigurationOutlineCard.swift @@ -308,6 +308,7 @@ struct ClashMacConfigurationOutlineCard: View { case .general: return .copy("General Configuration (Configuration Chapter)") case .dns: return .copy("DNS") case .sniffer: return .copy("Domain Sniffing (Configuration Chapter)") + case .mitm: return .copy("MITM Interception (Configuration Chapter)") case .inbound: return .copy("Inbounds (Configuration Chapter)") case .proxies: return .copy("Proxies (Configuration Chapter)") case .proxyProviders: return .copy("Proxy Providers (Configuration Chapter)") @@ -352,6 +353,8 @@ struct ClashMacConfigurationOutlineCard: View { switch (row.section, row.enabled) { case (.sniffer, false?): return (.copy("Off"), nil) case (.sniffer, true?): return (.copy("On"), nil) + case (.mitm, false?): return (.copy("Off"), nil) + case (.mitm, true?): return (.copy("On"), nil) default: return (.verbatim(""), nil) } } diff --git a/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacPageObjects.swift b/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacPageObjects.swift index 746ce83..59c556b 100644 --- a/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacPageObjects.swift +++ b/apple/ClashMacClient/Sources/ClashMacClient/ConfigurationCenter/ClashMacPageObjects.swift @@ -137,7 +137,7 @@ struct ClashMacSearchField: Hashable, Identifiable { static func index(_ catalog: ConfigurationFieldCatalog, copy: ConfigurationFieldCopy, locale: Locale, lanShareKeys: Set = []) -> [ClashMacSearchField] { - let pages: Set = [.general, .dns, .sniffer, .inbound, .ntp, .experimental] + let pages: Set = [.general, .dns, .sniffer, .mitm, .inbound, .ntp, .experimental] return catalog.fields.compactMap { field in guard !field.path.contains("["), !field.path.contains("*") else { return nil } let parts = field.path.split(separator: ".").map(String.init)