diff --git a/CONTEXT.md b/CONTEXT.md index 72b7b0ef..688d41f4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -11,7 +11,7 @@ - **Active target**: The persisted server configuration or configuration group selected for proxying. When a group is active, the group UUID remains the target even when `sslocal` chooses an individual descendant server. - **Activation candidate**: A server configuration with no known local blocking problem for proxy configuration generation. This status does not guarantee remote reachability or correctness of opaque plugin options. - **Managed plugin**: A SIP003 plugin executable that the product packages, hash-verifies, re-signs, and distributes only inside the app bundle. The managed set changes only with app releases; users cannot supply their own plugin executables. -- **Proxy mode**: The user-facing way traffic is routed through the local proxy: rule mode, global (public targets default through Shadowsocks, fixed local targets stay direct via a minimal ACL), or direct mode through an ACL-backed local SOCKS endpoint. All three project a local SOCKS system-proxy target. +- **Proxy mode**: The user-facing way traffic is routed through the local proxy: rule mode, global (public targets default through Shadowsocks, fixed local targets stay direct via a minimal ACL), or direct mode through an ACL-backed local SOCKS endpoint. All three project the local SOCKS and HTTP inbounds as the system-proxy target. - **Rule mode**: The ACL-backed routing mode in which built-in (and later custom) rules choose proxy versus direct per target; the unmatched default action is a separate persisted sub-option. - **Rule default action**: The rule-mode sub-option that decides proxy versus direct for targets no rule matches. Factory default is proxy-when-unmatched. _Avoid_: fallback (ambiguous). - **Built-in rule snapshot**: An app-shipped, pinned rule set produced by an explicit maintainer update; ordinary builds only read local snapshots and never fetch or convert. Three sources ship today: **geolocation-cn** (China domain direct candidates), **china-ipv4** (China mainland IPv4 CIDR direct candidates from gaoyifan/china-operator-ip), and **gfwlist** (proxy candidates from the official Base64 AutoProxy GFWList; only rules losslessly expressible as target domains, with `@@` exceptions shadowed by a broader proxy rule omitted and reported). Each snapshot carries its own source identity, digest, license, attribution, and conversion loss report. @@ -78,7 +78,7 @@ - Exported GUI event lines are an allowlisted, field-cleaned subset of runtime events; raw event detail remains available only through the explicit diagnostic log view. - A diagnostic report presents fixed proxy-state categories rather than free-form failure detail. If its safe projection cannot be constructed, no report is emitted; an unavailable individual fact source remains a marked partial fact instead. - A diagnostic export is considered complete only after the user-selected report file is successfully written; opening the export panel or preparing report text is not an export completion. -- Proxy modes are mutually exclusive: rule mode with proxy-when-unmatched writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain and China IPv4 CIDR direct candidates from the built-in snapshots; rule mode with direct-when-unmatched runs sslocal with a `[bypass_all]` ACL whose proxy list holds only expressible, unshadowed GFWList proxy candidates (the two default actions never unconditionally union all built-in sources), so unmatched targets and IP literals with no matching rule go direct; global writes the local SOCKS proxy and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS proxy and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. IP-CIDR matching may cause sslocal to issue local DNS queries for unmatched hostnames; the product does not promise that all DNS queries travel through the remote Shadowsocks server. In every mode the SystemConfiguration exceptions include the same fixed local scope — IPv4 loopback, RFC1918, link-local, IPv6 loopback, link-local, unique-local, `localhost`, `.local`, and no-dot hostnames — merged with the user's exception list; CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass; the ACL is the routing guarantee for requests that enter sslocal, including LAN-sharing clients. GFWList `@@` exceptions shadowed by a broader proxy rule are omitted from the ACL and recorded in the conversion report; the product does not claim complete reproduction of GFWList URL/protocol conditions. A change to the active ACL's summary or digest requires a full sslocal restart; the ACL's runtime path is a stable reference and does not change when the proxy mode switches. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When a rule/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on. Only traffic that honors the system SOCKS setting or explicitly connects to the local inbound is routed; the product does not claim interception of all application or UDP traffic. +- Proxy modes are mutually exclusive: rule mode with proxy-when-unmatched writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with a `[proxy_all]` ACL whose bypass list is the fixed local safety rules plus China domain and China IPv4 CIDR direct candidates from the built-in snapshots; rule mode with direct-when-unmatched runs sslocal with a `[bypass_all]` ACL whose proxy list holds only expressible, unshadowed GFWList proxy candidates (the two default actions never unconditionally union all built-in sources), so unmatched targets and IP literals with no matching rule go direct; global writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with a `[proxy_all]` ACL whose bypass list is only the fixed local safety rules (no China list, GFWList, or custom rules); direct mode writes the local SOCKS and HTTP/HTTPS system proxies and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. IP-CIDR matching may cause sslocal to issue local DNS queries for unmatched hostnames; the product does not promise that all DNS queries travel through the remote Shadowsocks server. In every mode the SystemConfiguration exceptions include the same fixed local scope — IPv4 loopback, RFC1918, link-local, IPv6 loopback, link-local, unique-local, `localhost`, `.local`, and no-dot hostnames — merged with the user's exception list; CGNAT is excluded. IPv6 CIDR strings in system exceptions are not a verified bypass; the ACL is the routing guarantee for requests that enter sslocal, including LAN-sharing clients. GFWList `@@` exceptions shadowed by a broader proxy rule are omitted from the ACL and recorded in the conversion report; the product does not claim complete reproduction of GFWList URL/protocol conditions. A change to the active ACL's summary or digest requires a full sslocal restart; the ACL's runtime path is a stable reference and does not change when the proxy mode switches. The wrapper publishes the ACL-backed receipt only after the child owns every configured TCP listener; the controller retains the previous mode, runtime, and applied system proxy until this instance is verified, and restores the previous runtime on verification failure. System proxy writes are allowed only while the system proxy switch is on and the agent is healthy with an available exit; 2.0 stores the complete per-service Proxies dictionaries it owns and restores them only while the applied values still match, otherwise it reports an ownership conflict. When a rule/global active target becomes invalid, the agent keeps listening but the held system proxy configuration is safely withdrawn while the system proxy intent stays on. Only traffic that honors the system SOCKS or HTTP/HTTPS proxy settings or explicitly connects to a local inbound is routed; the product does not claim interception of all application or UDP traffic. - Preferences have no single restore-all-factory-defaults action. Where a reset exists, it is scoped to one configuration item, never to the whole preference set at once. - The proxy mode selector's choice and the rule default action persist with the settings snapshot and survive GUI restarts; a persistence failure keeps the previous mode and sub-option in force and names the reason. Rule, global, and direct mode are always available. - The GUI is the user-facing manager; the Shadowsocks tunnel service is an external runtime boundary rather than part of the GUI's domain model. diff --git a/ShadowsocksX-NG2/App/ErrorPresentation.swift b/ShadowsocksX-NG2/App/ErrorPresentation.swift index b50ca2fc..4c7442f4 100644 --- a/ShadowsocksX-NG2/App/ErrorPresentation.swift +++ b/ShadowsocksX-NG2/App/ErrorPresentation.swift @@ -255,6 +255,7 @@ extension AppPresentation { static func proxyMode(_ error: ProxyModeError) -> String { switch error { case .invalidSOCKSPort: return "SOCKS 端口无效" + case .invalidHTTPPort: return "HTTP 端口无效" } } diff --git a/ShadowsocksX-NG2/App/HomeView.swift b/ShadowsocksX-NG2/App/HomeView.swift index 02baae9b..3ec177a0 100644 --- a/ShadowsocksX-NG2/App/HomeView.swift +++ b/ShadowsocksX-NG2/App/HomeView.swift @@ -193,7 +193,7 @@ private struct ModeCard: View { case .rule: "按内置规则在代理与直连间选择;未匹配目标走默认动作。" case .global: - "所有系统代理流量通过本地 SOCKS5 端点转发,不使用规则分流。" + "所有系统代理流量通过本地 SOCKS 与 HTTP 入口转发,不使用规则分流。" case .direct: "通过本地 SOCKS 与 HTTP 入口直连,不使用 Shadowsocks 服务器。" } diff --git a/ShadowsocksX-NG2/Domain/ProxyMode.swift b/ShadowsocksX-NG2/Domain/ProxyMode.swift index 48c5f6f7..b424134a 100644 --- a/ShadowsocksX-NG2/Domain/ProxyMode.swift +++ b/ShadowsocksX-NG2/Domain/ProxyMode.swift @@ -73,7 +73,8 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable { static var availableModes: [ProxyMode] { [.rule, .global, .direct] } /// Derives the only system-proxy state that this mode is allowed to own. - /// All three modes are projected as a local SOCKS target (issue #59/#67). + /// All three modes are projected as the local SOCKS and HTTP inbounds + /// (issue #59/#67):SOCKS 之外,系统 HTTP/HTTPS 代理指向恒开启的 HTTP 入站。 func systemProxyConfiguration( for document: SslocalRuntimeDocument, exceptions: [String]? = nil @@ -81,29 +82,41 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable { guard (1...65535).contains(document.socksPort) else { throw ProxyModeError.invalidSOCKSPort(document.socksPort) } + guard (1...65535).contains(document.httpPort) else { + throw ProxyModeError.invalidHTTPPort(document.httpPort) + } return SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: document.socksPort), + socks: .init(host: "127.0.0.1", port: document.socksPort), + http: .init(host: "127.0.0.1", port: document.httpPort), exceptions: FixedLocalProxyRanges.systemProxyExceptions(including: exceptions)) } } /// The part of the system proxy dictionary that 2.0 intentionally controls. +/// SOCKS 与 HTTP/HTTPS 两个协议族同时写入,各指向 sslocal 的对应本地入站; +/// HTTPS 走 HTTP 入站的 CONNECT 代理,与 SOCKS 共用同一实例。 struct SystemProxyConfiguration: Equatable, Sendable { - enum Target: Equatable, Sendable { - case socks(host: String, port: Int) + /// 一个系统代理协议族指向的本地端点。 + struct Endpoint: Equatable, Sendable { + let host: String + let port: Int } - let target: Target + let socks: Endpoint + /// 系统 HTTP 与 HTTPS 代理共同指向的 HTTP 入站端点。 + let http: Endpoint /// `nil` preserves the user's original ExceptionsList. A non-nil value is /// explicitly owned by the app and is projected on every activation. let exceptions: [String]? - init(target: Target, exceptions: [String]? = nil) { - self.target = target + init(socks: Endpoint, http: Endpoint, exceptions: [String]? = nil) { + self.socks = socks + self.http = http self.exceptions = exceptions } } enum ProxyModeError: Error, Equatable, Sendable { case invalidSOCKSPort(Int) + case invalidHTTPPort(Int) } diff --git a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift index 71e491cb..a70cafbc 100644 --- a/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/SslocalRuntimeDocument.swift @@ -216,6 +216,11 @@ extension SslocalRuntimeDocument { var socksPort: Int { socksLocal?.localPort ?? 0 } var socksMode: String { socksLocal?.mode ?? "" } + /// HTTP 入站端口;缺 HTTP 入站时为 0(无效端口,系统代理投影层拒绝)。 + var httpPort: Int { + locals.first { $0.inboundProtocol == "http" }?.localPort ?? 0 + } + /// wrapper 读取侧防御校验:端口、协议与共享范围必须一致;无效文件按 D5 /// 停止并清理,不能交给 KeepAlive 无限重放。空 `servers` 合法(issue #60): /// 无活动目标时 agent 以空服务器列表提供本地监听;上游 sslocal v1.25.0 diff --git a/ShadowsocksX-NG2/Domain/SystemProxyPropertyList.swift b/ShadowsocksX-NG2/Domain/SystemProxyPropertyList.swift index 08de82d8..2c4fb704 100644 --- a/ShadowsocksX-NG2/Domain/SystemProxyPropertyList.swift +++ b/ShadowsocksX-NG2/Domain/SystemProxyPropertyList.swift @@ -3,11 +3,15 @@ import Foundation /// Pure projection of one SystemConfiguration Proxies dictionary. Keeping the /// key mapping here makes the mutually exclusive enablement rules testable /// without opening a real SCPreferences session. PAC projection is gone -/// (issue #67); the product writes a SOCKS target only. +/// (issue #67);SOCKS 与 HTTP/HTTPS 同时指向本地入站(ADR 0012)。 enum SystemProxyPropertyList { static let httpEnabled = "HTTPEnable" static let httpsEnabled = "HTTPSEnable" static let socksEnabled = "SOCKSEnable" + static let httpPort = "HTTPPort" + static let httpProxy = "HTTPProxy" + static let httpsPort = "HTTPSPort" + static let httpsProxy = "HTTPSProxy" static let socksPort = "SOCKSPort" static let socksProxy = "SOCKSProxy" static let pacEnabled = "ProxyAutoConfigEnable" @@ -20,30 +24,29 @@ enum SystemProxyPropertyList { _ configuration: SystemProxyConfiguration, to original: [String: Any] ) -> [String: Any] { var dictionary = original + dictionary[socksEnabled] = 0 dictionary[httpEnabled] = 0 dictionary[httpsEnabled] = 0 - dictionary[socksEnabled] = 0 - // 仍然清掉 PAC/自动发现,确保与 SOCKS 目标互斥(D8)。 + // 仍然清掉 PAC/自动发现,确保与本地入站目标互斥(D8)。 dictionary[pacEnabled] = 0 dictionary[autoDiscoveryEnabled] = 0 dictionary.removeValue(forKey: pacJavaScript) dictionary.removeValue(forKey: pacURL) - switch configuration.target { - case .socks(let host, let port): - dictionary[socksEnabled] = 1 - dictionary[socksProxy] = host - dictionary[socksPort] = port - } + dictionary[socksEnabled] = 1 + dictionary[socksProxy] = configuration.socks.host + dictionary[socksPort] = configuration.socks.port + dictionary[httpEnabled] = 1 + dictionary[httpProxy] = configuration.http.host + dictionary[httpPort] = configuration.http.port + // HTTPS 系统代理 = HTTP 入站承接的 CONNECT 代理,端点与 HTTP 相同。 + dictionary[httpsEnabled] = 1 + dictionary[httpsProxy] = configuration.http.host + dictionary[httpsPort] = configuration.http.port + if let exceptions = configuration.exceptions { dictionary[exceptionsList] = exceptions } return dictionary } - - static func applying( - _ target: SystemProxyConfiguration.Target, to original: [String: Any] - ) -> [String: Any] { - applying(SystemProxyConfiguration(target: target), to: original) - } } diff --git a/ShadowsocksX-NG2/Tests/ProxyModeAvailabilityTests.swift b/ShadowsocksX-NG2/Tests/ProxyModeAvailabilityTests.swift index 54642353..1288f78a 100644 --- a/ShadowsocksX-NG2/Tests/ProxyModeAvailabilityTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyModeAvailabilityTests.swift @@ -18,23 +18,29 @@ final class ProxyModeAvailabilityTests: XCTestCase { let configuration = try ProxyMode.direct.systemProxyConfiguration(for: document) XCTAssertEqual( - configuration.target, - .socks(host: "127.0.0.1", port: SslocalListenSettings.defaultSocksPort)) + configuration.socks, + .init(host: "127.0.0.1", port: SslocalListenSettings.defaultSocksPort)) + XCTAssertEqual( + configuration.http, + .init(host: "127.0.0.1", port: SslocalListenSettings.defaultHTTPPort)) XCTAssertEqual( configuration.exceptions, FixedLocalProxyRanges.systemProxyExceptions) } - /// 全局模式(issue #62):系统 SOCKS 投影 + 固定本地例外,与直连共用同一 - /// 安全范围;公网路由由 ACL 决定,不由系统例外决定。 + /// 全局模式(issue #62):系统 SOCKS/HTTP 投影 + 固定本地例外,与直连共用 + /// 同一安全范围;公网路由由 ACL 决定,不由系统例外决定。 func testGlobalModeUsesLocalSOCKSAndIncludesFixedBypasses() throws { let document = SslocalRuntimeDocument(servers: [], listen: SslocalListenSettings()) let configuration = try ProxyMode.global.systemProxyConfiguration(for: document) XCTAssertEqual( - configuration.target, - .socks(host: "127.0.0.1", port: SslocalListenSettings.defaultSocksPort)) + configuration.socks, + .init(host: "127.0.0.1", port: SslocalListenSettings.defaultSocksPort)) + XCTAssertEqual( + configuration.http, + .init(host: "127.0.0.1", port: SslocalListenSettings.defaultHTTPPort)) XCTAssertEqual( configuration.exceptions, FixedLocalProxyRanges.systemProxyExceptions) diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift index b88d69b2..17350ca2 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift @@ -86,7 +86,8 @@ extension ProxyRuntimeControllerTests { systemProxy.applied, [ SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: ActivationFixture.listen.socksPort), + socks: .init(host: "127.0.0.1", port: ActivationFixture.listen.socksPort), + http: .init(host: "127.0.0.1", port: ActivationFixture.listen.httpPort), exceptions: FixedLocalProxyRanges.systemProxyExceptions( including: ProxySettings().proxyExceptionList)) ], diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+GlobalMode.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+GlobalMode.swift index a43b065c..a06c9c6d 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+GlobalMode.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+GlobalMode.swift @@ -36,8 +36,11 @@ extension ProxyRuntimeControllerTests { Data(expectedACL.content.utf8), "链接解析后的内容是 global 变体") XCTAssertEqual( - systemProxy.applied.last?.target, - .socks(host: "127.0.0.1", port: ActivationFixture.listen.socksPort)) + systemProxy.applied.last?.socks, + .init(host: "127.0.0.1", port: ActivationFixture.listen.socksPort)) + XCTAssertEqual( + systemProxy.applied.last?.http, + .init(host: "127.0.0.1", port: ActivationFixture.listen.httpPort)) XCTAssertTrue( Set(FixedLocalProxyRanges.systemProxyExceptions).isSubset( of: Set(systemProxy.applied.last?.exceptions ?? [])), @@ -83,8 +86,11 @@ extension ProxyRuntimeControllerTests { controller.systemProxyState, .applied, "服务器有效且健康后自动收敛待应用意图") XCTAssertEqual( - systemProxy.applied.last?.target, - .socks(host: "127.0.0.1", port: ActivationFixture.listen.socksPort)) + systemProxy.applied.last?.socks, + .init(host: "127.0.0.1", port: ActivationFixture.listen.socksPort)) + XCTAssertEqual( + systemProxy.applied.last?.http, + .init(host: "127.0.0.1", port: ActivationFixture.listen.httpPort)) } func testFailedGlobalInstanceRestoresOldModeRuntimeAndSystemProxy() async throws { diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+Mode.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+Mode.swift index a8936463..a779439b 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+Mode.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+Mode.swift @@ -70,8 +70,11 @@ extension ProxyRuntimeControllerTests { Data(expectedACL.content.utf8), "链接解析后的内容是 direct 变体") XCTAssertEqual( - systemProxy.applied.last?.target, - .socks(host: "127.0.0.1", port: ActivationFixture.listen.socksPort)) + systemProxy.applied.last?.socks, + .init(host: "127.0.0.1", port: ActivationFixture.listen.socksPort)) + XCTAssertEqual( + systemProxy.applied.last?.http, + .init(host: "127.0.0.1", port: ActivationFixture.listen.httpPort)) XCTAssertTrue( Set(FixedLocalProxyRanges.systemProxyExceptions).isSubset( of: Set(systemProxy.applied.last?.exceptions ?? []))) diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index 96c7ff02..4a7e1dcc 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -190,7 +190,8 @@ final class ProxyRuntimeControllerTests: XCTestCase { systemProxy.applied, [ SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: 11086), + socks: .init(host: "127.0.0.1", port: 11086), + http: .init(host: "127.0.0.1", port: 11087), exceptions: FixedLocalProxyRanges.systemProxyExceptions( including: ProxySettings().proxyExceptionList)) ]) @@ -201,7 +202,8 @@ final class ProxyRuntimeControllerTests: XCTestCase { XCTAssertEqual( systemProxy.applied.last, SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: 11086), + socks: .init(host: "127.0.0.1", port: 11086), + http: .init(host: "127.0.0.1", port: 11087), exceptions: FixedLocalProxyRanges.systemProxyExceptions( including: ProxySettings().proxyExceptionList))) diff --git a/ShadowsocksX-NG2/Tests/SystemProxyTests.swift b/ShadowsocksX-NG2/Tests/SystemProxyTests.swift index 97bed3b9..5542e26e 100644 --- a/ShadowsocksX-NG2/Tests/SystemProxyTests.swift +++ b/ShadowsocksX-NG2/Tests/SystemProxyTests.swift @@ -5,24 +5,40 @@ import XCTest /// System proxy mode mapping and ownership persistence are pure/testable seams; /// no test in this file writes the host's real SystemConfiguration state. final class SystemProxyTests: XCTestCase { - func testSupportedModesProjectTheSameLocalSOCKSTarget() throws { + func testSupportedModesProjectTheSameLocalEndpoints() throws { let document = ProxyRuntimeFixture.makeDocument( localAddress: "192.168.2.89", localPort: 2086) + let expected = SystemProxyConfiguration( + socks: .init(host: "127.0.0.1", port: 2086), + http: .init(host: "127.0.0.1", port: SslocalListenSettings.defaultHTTPPort), + exceptions: FixedLocalProxyRanges.systemProxyExceptions) XCTAssertEqual( try ProxyMode.rule.systemProxyConfiguration(for: document), - SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: 2086), - exceptions: FixedLocalProxyRanges.systemProxyExceptions)) + expected) XCTAssertEqual( try ProxyMode.global.systemProxyConfiguration(for: document), - SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: 2086), - exceptions: FixedLocalProxyRanges.systemProxyExceptions), + expected, "全局模式的系统例外使用固定本地范围,与 ACL 安全策略一致") } - func testSystemConfigurationProjectionEnablesOnlySOCKS() { + func testMissingHTTPInboundIsRejectedInsteadOfPointingAtAWildcardPort() throws { + // 防御路径:well-formed 文档允许缺 HTTP 入站;此时不得把系统代理指向 + // 无效端口 0,而应按模式错误拒绝。 + let json = """ + {"servers":[],"locals":[{"protocol":"socks","local_address":"127.0.0.1",\ + "local_port":11086,"mode":"tcp_and_udp"}],"timeout":60,\ + "x_shadowsocksx_ng_listen":{"listen_scope":"loopback",\ + "bind_address":"127.0.0.1","advertised_address":"127.0.0.1","verbose":false}} + """ + let document = try XCTUnwrap(SslocalRuntimeDocument.decodeValidated(Data(json.utf8))) + + XCTAssertThrowsError(try ProxyMode.rule.systemProxyConfiguration(for: document)) { error in + XCTAssertEqual(error as? ProxyModeError, .invalidHTTPPort(0)) + } + } + + func testSystemConfigurationProjectionEnablesSOCKSHTTPAndHTTPS() { let original: [String: Any] = [ SystemProxyPropertyList.httpEnabled: 1, SystemProxyPropertyList.httpsEnabled: 1, @@ -32,19 +48,27 @@ final class SystemProxyTests: XCTestCase { "ExceptionsList": ["localhost"], ] - let socks = SystemProxyPropertyList.applying( - .socks(host: "127.0.0.1", port: 1086), to: original) - XCTAssertEqual(socks[SystemProxyPropertyList.pacEnabled] as? Int, 0) - XCTAssertNil(socks[SystemProxyPropertyList.pacURL]) - XCTAssertEqual(socks[SystemProxyPropertyList.socksEnabled] as? Int, 1) - XCTAssertEqual(socks[SystemProxyPropertyList.socksProxy] as? String, "127.0.0.1") - XCTAssertEqual(socks[SystemProxyPropertyList.socksPort] as? Int, 1086) - XCTAssertEqual(socks[SystemProxyPropertyList.httpEnabled] as? Int, 0) - XCTAssertEqual(socks[SystemProxyPropertyList.httpsEnabled] as? Int, 0) + let applied = SystemProxyPropertyList.applying( + SystemProxyConfiguration( + socks: .init(host: "127.0.0.1", port: 1086), + http: .init(host: "127.0.0.1", port: 1087)), + to: original) + XCTAssertEqual(applied[SystemProxyPropertyList.pacEnabled] as? Int, 0) + XCTAssertNil(applied[SystemProxyPropertyList.pacURL]) + XCTAssertEqual(applied[SystemProxyPropertyList.socksEnabled] as? Int, 1) + XCTAssertEqual(applied[SystemProxyPropertyList.socksProxy] as? String, "127.0.0.1") + XCTAssertEqual(applied[SystemProxyPropertyList.socksPort] as? Int, 1086) + XCTAssertEqual(applied[SystemProxyPropertyList.httpEnabled] as? Int, 1) + XCTAssertEqual(applied[SystemProxyPropertyList.httpProxy] as? String, "127.0.0.1") + XCTAssertEqual(applied[SystemProxyPropertyList.httpPort] as? Int, 1087) + XCTAssertEqual(applied[SystemProxyPropertyList.httpsEnabled] as? Int, 1) + XCTAssertEqual(applied[SystemProxyPropertyList.httpsProxy] as? String, "127.0.0.1") + XCTAssertEqual(applied[SystemProxyPropertyList.httpsPort] as? Int, 1087) let ownedExceptions = SystemProxyPropertyList.applying( SystemProxyConfiguration( - target: .socks(host: "127.0.0.1", port: 1086), + socks: .init(host: "127.0.0.1", port: 1086), + http: .init(host: "127.0.0.1", port: 1087), exceptions: ["localhost", "127.0.0.1"]), to: original) XCTAssertEqual( diff --git a/docs/adr/0012-system-proxy-covers-http-https.md b/docs/adr/0012-system-proxy-covers-http-https.md new file mode 100644 index 00000000..a09d0e8f --- /dev/null +++ b/docs/adr/0012-system-proxy-covers-http-https.md @@ -0,0 +1,7 @@ +# Write HTTP and HTTPS system proxies alongside SOCKS + +**Status**: accepted + +Enabling the system proxy switch writes the macOS HTTP and HTTPS proxy entries in addition to the SOCKS entry (ADR-0007 had disabled the HTTP/HTTPS families and projected a SOCKS target only). All three families point at 2.0's own local inbounds: SOCKS at the SOCKS5 listener, and HTTP and HTTPS at the always-on local HTTP inbound — shadowsocks-rust serves HTTP CONNECT directly, so no Privoxy-style bridge returns. HTTPS and HTTP share one endpoint because both are served by the same CONNECT-capable inbound. Exceptions, PAC/auto-discovery suppression, and the ownership snapshot/restore protocol are unchanged. + +The launch health gate already probes every configured inbound, so the HTTP endpoint must be reachable before any system proxy write happens. A runtime document without a usable HTTP inbound port is rejected by the mode projection (`invalidHTTPPort`) instead of pointing the system at an invalid port. NG2 has not shipped, so no migration of previously applied system-proxy state is required: the next convergence writes the new shape, and restore keeps returning the pre-app snapshot.