mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-27 20:53:44 +00:00
feat(proxy): add ACL-backed global mode with local bypass
Global mode now deploys a minimal proxy_all ACL carrying only the fixed local safety rules, so public targets default through Shadowsocks while loopback/RFC1918/link-local/ULA/localhost/*.local/no-dot hosts stay direct for SOCKS, HTTP, and LAN-sharing clients. System proxy exceptions include that fixed scope; IPv6 system-exception limits are documented with the ACL as the routing guarantee. Mode switches keep the full-restart and rollback path. Closes #62
This commit is contained in:
+5
-5
@@ -11,9 +11,9 @@
|
||||
- **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: local PAC, global SOCKS, or direct mode through an ACL-backed local SOCKS endpoint.
|
||||
- **Proxy mode**: The user-facing way traffic is routed through the local proxy: local PAC, 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.
|
||||
- **Local PAC**: The PAC document generated and served by the 2.0 proxy runtime; it is distinct from a user-provided remote PAC URL.
|
||||
- **Proxy mode selector**: The common user action for choosing local PAC, global SOCKS, or direct mode; it is distinct from enabling or disabling the proxy runtime, from the system proxy switch, and from editing PAC user rules. Its choice persists across GUI restarts.
|
||||
- **Proxy mode selector**: The common user action for choosing local PAC, global, or direct mode; it is distinct from enabling or disabling the proxy runtime, from the system proxy switch, and from editing PAC user rules. Its choice persists across GUI restarts.
|
||||
- **Agent switch**: The user's independent on/off choice for the proxy agent (the per-user background proxy runtime). It defaults to on, so a first run starts the agent and keeps local SOCKS/HTTP listening even before any active target exists; an explicit off is persisted, survives GUI restarts, and quitting the GUI never stops the launchd-hosted agent. Turning the agent off first restores the system proxy settings 2.0 holds and then stops the local listeners. _Avoid_: proxy switch (ambiguous with the system proxy switch).
|
||||
- **System proxy switch**: The user's independent choice to have 2.0 take over macOS system proxy settings. It defaults to off, so a first run never rewrites the user's proxy dictionary. Turning it off restores only the settings 2.0 holds and never unregisters the agent or stops the local SOCKS/HTTP listeners. Its intent (persisted), actual application, and typed failures are presented as separate facts, distinct from the agent's run state. _Avoid_: proxy switch (ambiguous with the agent switch).
|
||||
- **Login item**: The GUI app's registration in the user's login items. It is an independent preference domain: off until the user enables it, its state is only ever the system login-item registration status, and resetting preferences never changes it.
|
||||
@@ -68,15 +68,15 @@
|
||||
- 2.0 and Legacy are independent applications that may run concurrently. 2.0 never stops Legacy processes, retires Legacy runtime jobs, or rewrites Legacy-owned system proxy settings; nothing transfers between them except the server records brought over by a Legacy import. Their factory-default local endpoint ports are distinct so neither app's defaults collide with the other's.
|
||||
- The PAC HTTP endpoint and the external tunnel service's inbound listeners follow one shared listen scope, so a PAC URL or proxy address shared to another machine always points at a reachable proxy; loopback is the default scope.
|
||||
- The ports of the locally provided proxy endpoints are explicit user configuration: the system never rewrites a port on its own, and a port conflict rejects proxy start with an error naming the endpoint until the user explicitly chooses a new port. A recovery suggestion only proposes candidate ports (32768–65535, free and distinct from the other endpoints' configured values) and applies only after the user confirms and saves; changing the PAC port warns that previously shared PAC URLs become invalid.
|
||||
- The PAC HTTP endpoint and the external tunnel service start and stop together inside the proxy runtime: both are reachable exactly while the agent is running, and neither depends on the GUI being open. The agent runs when the agent switch says so: on by default, and — even without an active target — it keeps listening with an empty server list. The GUI writes the system proxy configuration only when the system proxy switch is on, endpoint health passes, and the selected mode has an available exit; PAC and global SOCKS require an active server target, while direct mode uses an empty-proxy-list ACL and needs no server target. Otherwise the system proxy intent stays pending and converges automatically once the conditions recover.
|
||||
- The PAC HTTP endpoint and the external tunnel service start and stop together inside the proxy runtime: both are reachable exactly while the agent is running, and neither depends on the GUI being open. The agent runs when the agent switch says so: on by default, and — even without an active target — it keeps listening with an empty server list. The GUI writes the system proxy configuration only when the system proxy switch is on, endpoint health passes, and the selected mode has an available exit; PAC and global require an active server target, while direct mode uses an empty-proxy-list ACL and needs no server target. Otherwise the system proxy intent stays pending and converges automatically once the conditions recover.
|
||||
- Diagnostic collection is read-only: it never changes the configuration catalog, credentials, proxy runtime, or system proxy settings. A diagnostic report is best-effort; an unavailable fact source is represented as unavailable rather than causing unrelated available facts to be discarded.
|
||||
- A diagnostic report contains aggregate catalog facts only. The complete configuration catalog, catalog entries, server addresses, remarks, and credential references never cross into the diagnostic report projection.
|
||||
- Raw wrapper log text may be viewed or copied only through the explicit diagnostic log view and never enters a diagnostic report or safe diagnostic projection.
|
||||
- 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: local PAC writes the runtime PAC URL; global SOCKS writes the local SOCKS proxy; direct mode writes the local SOCKS proxy and runs sslocal with `[bypass_all]`, so SOCKS and HTTP requests go direct without a Shadowsocks server. In direct mode, the SystemConfiguration exceptions and ACL both include IPv4 loopback, RFC1918, link-local, IPv6 loopback, link-local, unique-local, `localhost`, `.local`, and no-dot hostnames; CGNAT is excluded. ACL path, content, digest, or summary changes require a full sslocal restart. The wrapper publishes the direct-mode 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 PAC/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.
|
||||
- The proxy mode selector's choice persists with the settings snapshot and survives GUI restarts; a persistence failure keeps the previous mode in force and names the reason. Local PAC, global SOCKS, and direct mode are always available, and user-provided remote PAC URLs are not accepted as proxy settings. Resetting preferences returns the mode to local PAC.
|
||||
- Proxy modes are mutually exclusive: local PAC writes the runtime PAC URL; 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. In both ACL-backed modes 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. ACL path, content, digest, or summary changes require a full sslocal restart. 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 PAC/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.
|
||||
- The proxy mode selector's choice persists with the settings snapshot and survives GUI restarts; a persistence failure keeps the previous mode in force and names the reason. Local PAC, global, and direct mode are always available, and user-provided remote PAC URLs are not accepted as proxy settings. Resetting preferences returns the mode to local PAC.
|
||||
- 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.
|
||||
- The presentation form is governed solely by the workspace window: opening it enters the foreground form, and closing it returns the app to the background form while the GUI process keeps running with the status menu available; hiding the window without closing it does not leave the foreground form.
|
||||
- Every launch of the GUI presents the workspace window at startup.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension ProxyRuntimeController {
|
||||
/// The choice persists with the settings snapshot. Direct-mode transitions
|
||||
/// also deploy and verify the ACL-backed runtime even when system proxy is off,
|
||||
/// because manually connected SOCKS and HTTP clients use the same ACL.
|
||||
/// The choice persists with the settings snapshot. ACL-backed modes (direct
|
||||
/// and global) also deploy and verify the ACL runtime even when system proxy
|
||||
/// is off, because manually connected SOCKS and HTTP clients use the same ACL.
|
||||
func setProxyMode(_ mode: ProxyMode) async {
|
||||
guard ProxyMode.availableModes.contains(mode) else { return }
|
||||
guard mode != proxyMode else { return }
|
||||
@@ -53,8 +53,14 @@ extension ProxyRuntimeController {
|
||||
_ document: SslocalRuntimeDocument,
|
||||
for mode: ProxyMode
|
||||
) -> SslocalRuntimeDocument {
|
||||
guard mode == .direct else { return document.replacingACL(nil) }
|
||||
return document.replacingACL(.direct(at: runtimeFileStore.aclFileURL))
|
||||
switch mode {
|
||||
case .direct:
|
||||
return document.replacingACL(.direct(at: runtimeFileStore.aclFileURL))
|
||||
case .global:
|
||||
return document.replacingACL(.global(at: runtimeFileStore.aclFileURL))
|
||||
case .pac:
|
||||
return document.replacingACL(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func deployModeTransition(
|
||||
|
||||
@@ -62,10 +62,26 @@ struct ProxyACLDocument: Codable, Equatable, Sendable {
|
||||
/// 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 {
|
||||
let lines = ["[bypass_all]", "[bypass_list]"] + FixedLocalProxyRanges.aclBypassRules
|
||||
makeDocument(
|
||||
at: fileURL, header: "[bypass_all]", summary: "direct")
|
||||
}
|
||||
|
||||
/// 全局模式(issue #62):公网目标默认走 Shadowsocks 代理,本地网络目标固定
|
||||
/// 直连。ACL 只含固定本地安全绕过,不混入中国列表、GFWList 或自定义规则;
|
||||
/// SOCKS、HTTP 入站与局域网共享客户端共用此策略。IPv6 系统例外不构成已验证
|
||||
/// 的绕过保证,本 ACL 才是路由兜底。
|
||||
static func global(at fileURL: URL) -> ProxyACLDocument {
|
||||
makeDocument(
|
||||
at: fileURL, header: "[proxy_all]", summary: "global")
|
||||
}
|
||||
|
||||
private static func makeDocument(
|
||||
at fileURL: URL, header: String, summary: String
|
||||
) -> ProxyACLDocument {
|
||||
let lines = [header, "[bypass_list]"] + FixedLocalProxyRanges.aclBypassRules
|
||||
return ProxyACLDocument(
|
||||
path: fileURL.standardizedFileURL.path,
|
||||
summary: "direct",
|
||||
summary: summary,
|
||||
content: lines.joined(separator: "\n") + "\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -81,13 +81,7 @@ enum ProxyMode: Codable, Equatable, Hashable, Sendable {
|
||||
throw ProxyModeError.invalidLocalPACURL
|
||||
}
|
||||
return SystemProxyConfiguration(target: .pac(url), exceptions: exceptions)
|
||||
case .global:
|
||||
guard (1...65535).contains(document.socksPort) else {
|
||||
throw ProxyModeError.invalidSOCKSPort(document.socksPort)
|
||||
}
|
||||
return SystemProxyConfiguration(
|
||||
target: .socks(host: "127.0.0.1", port: document.socksPort), exceptions: exceptions)
|
||||
case .direct:
|
||||
case .global, .direct:
|
||||
guard (1...65535).contains(document.socksPort) else {
|
||||
throw ProxyModeError.invalidSOCKSPort(document.socksPort)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,36 @@ final class ProxyACLTests: XCTestCase {
|
||||
XCTAssertFalse(acl.content.contains("100.64.0.0/10"))
|
||||
}
|
||||
|
||||
/// 全局模式(issue #62):只含固定本地绕过的最小 ACL;不混入中国列表、
|
||||
/// GFWList 或自定义规则。公网目标默认代理,本地目标固定直连。
|
||||
func testGlobalACLUsesProxyAllAndOnlyFixedLocalSafetyRules() {
|
||||
let acl = ProxyACLDocument.global(
|
||||
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"))
|
||||
|
||||
XCTAssertTrue(acl.isWellFormed)
|
||||
XCTAssertEqual(acl.summary, "global")
|
||||
XCTAssertTrue(acl.content.hasPrefix("[proxy_all]\n[bypass_list]\n"))
|
||||
XCTAssertFalse(acl.content.contains("[proxy_list]"))
|
||||
XCTAssertFalse(acl.content.contains("[bypass_all]"))
|
||||
XCTAssertTrue(acl.content.contains("127.0.0.0/8\n"))
|
||||
XCTAssertTrue(acl.content.contains("10.0.0.0/8\n"))
|
||||
XCTAssertTrue(acl.content.contains("172.16.0.0/12\n"))
|
||||
XCTAssertTrue(acl.content.contains("192.168.0.0/16\n"))
|
||||
XCTAssertTrue(acl.content.contains("169.254.0.0/16\n"))
|
||||
XCTAssertTrue(acl.content.contains("::1/128\n"))
|
||||
XCTAssertTrue(acl.content.contains("fe80::/10\n"))
|
||||
XCTAssertTrue(acl.content.contains("fc00::/7\n"))
|
||||
XCTAssertTrue(acl.content.contains("||localhost\n"))
|
||||
XCTAssertTrue(acl.content.contains("||local\n"))
|
||||
XCTAssertTrue(acl.content.contains("^[^.]+$\n"))
|
||||
XCTAssertFalse(acl.content.contains("100.64.0.0/10"), "不加入 CGNAT")
|
||||
// 仅固定本地绕过:不携带任何来源规则或用户规则痕迹。
|
||||
let rules = Array(acl.content.split(separator: "\n").map(String.init).dropFirst(2))
|
||||
XCTAssertEqual(
|
||||
rules, FixedLocalProxyRanges.aclBypassRules,
|
||||
"全局 ACL 的规则区应与固定本地绕过完全一致")
|
||||
}
|
||||
|
||||
func testACLRejectsChangedDigestAndNonAbsolutePath() {
|
||||
let valid = ProxyACLDocument.direct(
|
||||
at: URL(fileURLWithPath: "/tmp/ssxng-test/sslocal-active.acl"))
|
||||
|
||||
@@ -24,4 +24,22 @@ final class ProxyModeAvailabilityTests: XCTestCase {
|
||||
configuration.exceptions,
|
||||
FixedLocalProxyRanges.systemProxyExceptions)
|
||||
}
|
||||
|
||||
/// 全局模式(issue #62):系统 SOCKS 投影 + 固定本地例外,与直连共用同一
|
||||
/// 安全范围;公网路由由 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))
|
||||
XCTAssertEqual(
|
||||
configuration.exceptions,
|
||||
FixedLocalProxyRanges.systemProxyExceptions)
|
||||
XCTAssertFalse(
|
||||
configuration.exceptions?.contains("100.64.0.0/10") ?? true,
|
||||
"不加入 CGNAT")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,8 @@ final class ProxyRuntimeControllerTests: XCTestCase {
|
||||
[
|
||||
SystemProxyConfiguration(
|
||||
target: .socks(host: "127.0.0.1", port: 11086),
|
||||
exceptions: ProxySettings().proxyExceptionList)
|
||||
exceptions: FixedLocalProxyRanges.systemProxyExceptions(
|
||||
including: ProxySettings().proxyExceptionList))
|
||||
])
|
||||
|
||||
await controller.setProxyMode(.pac)
|
||||
@@ -858,3 +859,121 @@ extension ProxyRuntimeControllerTests {
|
||||
XCTAssertEqual(systemProxy.restoreCount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局模式以 ACL 实现(issue #62):proxy_all + 固定本地绕过;无活动服务器
|
||||
/// 时 agent 仍监听、系统代理保持待应用;ACL 变化完整重启并失败回滚。
|
||||
extension ProxyRuntimeControllerTests {
|
||||
func testGlobalModeDeploysProxyAllACLAndProjectsSOCKSProxyWithFixedExceptions() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
settingsStore: settingsStore,
|
||||
settings: ProxySettings(
|
||||
listen: ActivationFixture.listen, systemProxyEnabled: true))
|
||||
try await controller.activate(seeded.server)
|
||||
XCTAssertEqual(controller.systemProxyState, .applied)
|
||||
let unregisterCount = agent.unregisterCount
|
||||
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
|
||||
|
||||
await controller.setProxyMode(.global)
|
||||
|
||||
XCTAssertEqual(controller.proxyMode, .global)
|
||||
XCTAssertEqual(controller.settings.preferredMode, .global)
|
||||
XCTAssertEqual(settingsStore.saved?.preferredMode, .global)
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
XCTAssertEqual(
|
||||
agent.unregisterCount, unregisterCount + 1, "ACL 变化触发完整 agent 重启")
|
||||
let document = try XCTUnwrap(runtimeStore.loadDocument())
|
||||
XCTAssertEqual(document.aclRuntime?.summary, "global")
|
||||
XCTAssertEqual(
|
||||
document.aclRuntime?.content.hasPrefix("[proxy_all]\n[bypass_list]\n"), true)
|
||||
XCTAssertEqual(
|
||||
try Data(contentsOf: runtimeStore.aclFileURL),
|
||||
Data(try XCTUnwrap(document.aclRuntime).content.utf8))
|
||||
XCTAssertEqual(
|
||||
systemProxy.applied.last?.target,
|
||||
.socks(host: "127.0.0.1", port: ActivationFixture.listen.socksPort))
|
||||
XCTAssertTrue(
|
||||
Set(FixedLocalProxyRanges.systemProxyExceptions).isSubset(
|
||||
of: Set(systemProxy.applied.last?.exceptions ?? [])),
|
||||
"系统例外包含固定本地范围")
|
||||
XCTAssertEqual(controller.systemProxyState, .applied)
|
||||
}
|
||||
|
||||
func testGlobalModeWithoutServerKeepsListeningAndPendingUntilTargetRecovers() async throws {
|
||||
// 目录先播种、控制器再创建:bootstrap 快照读取器持有已提交目录,但尚无
|
||||
// 活动目标——正是「全局模式 + 无可用出口」的起点。
|
||||
let seeded = try makeSeededCatalog()
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
settingsStore: settingsStore,
|
||||
settings: ProxySettings(
|
||||
listen: ActivationFixture.listen, systemProxyEnabled: true))
|
||||
await controller.resyncOnLaunch()
|
||||
XCTAssertEqual(controller.state, .running, "无活动目标时 agent 仍监听")
|
||||
XCTAssertNil(controller.activeTargetID)
|
||||
let unregisterCount = agent.unregisterCount
|
||||
|
||||
await controller.setProxyMode(.global)
|
||||
|
||||
XCTAssertEqual(controller.proxyMode, .global)
|
||||
XCTAssertEqual(controller.state, .running, "全局模式空服务器列表仍提供本地监听")
|
||||
XCTAssertEqual(
|
||||
agent.unregisterCount, unregisterCount + 1, "ACL 变化触发完整 agent 重启")
|
||||
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
|
||||
let document = try XCTUnwrap(runtimeStore.loadDocument())
|
||||
XCTAssertTrue(document.servers.isEmpty, "无活动目标时服务器列表为空")
|
||||
XCTAssertEqual(document.aclRuntime?.summary, "global")
|
||||
XCTAssertEqual(
|
||||
controller.systemProxyState, .pending,
|
||||
"无可用代理出口时系统代理意图保持待应用")
|
||||
XCTAssertTrue(systemProxy.applied.isEmpty, "不得把系统代理指向无出口入口")
|
||||
|
||||
// 服务器有效且端点健康后自动应用已开启的系统代理意图。
|
||||
try await controller.activate(seeded.server)
|
||||
|
||||
XCTAssertEqual(controller.state, .running)
|
||||
XCTAssertEqual(
|
||||
controller.systemProxyState, .applied,
|
||||
"服务器有效且健康后自动收敛待应用意图")
|
||||
XCTAssertEqual(
|
||||
systemProxy.applied.last?.target,
|
||||
.socks(host: "127.0.0.1", port: ActivationFixture.listen.socksPort))
|
||||
}
|
||||
|
||||
func testFailedGlobalInstanceRestoresOldModeRuntimeAndSystemProxy() async throws {
|
||||
let seeded = try makeSeededCatalog()
|
||||
let settingsStore = InMemoryProxySettingsStore()
|
||||
let controller = makeController(
|
||||
probe: ProxyRuntimeFixture.FakeProbe.reachable(),
|
||||
settingsStore: settingsStore,
|
||||
settings: ProxySettings(
|
||||
listen: ActivationFixture.listen, systemProxyEnabled: true),
|
||||
launchHealthTimeoutSeconds: 0.05)
|
||||
try await controller.activate(seeded.server)
|
||||
XCTAssertEqual(controller.systemProxyState, .applied)
|
||||
let runtimeStore = RuntimeFileStore(fileURL: runtime.contract)
|
||||
let previousDocument = try XCTUnwrap(runtimeStore.loadDocument())
|
||||
let previousApplicationCount = systemProxy.applied.count
|
||||
|
||||
agent.onRegister = { [runtimeStore, previousDocument] in
|
||||
guard let requested = runtimeStore.loadDocument() else { return }
|
||||
let accepted = requested.aclRuntime == nil ? requested : previousDocument
|
||||
try? runtimeStore.writeRuntimeReceipt(for: accepted, processID: 42)
|
||||
}
|
||||
|
||||
await controller.setProxyMode(.global)
|
||||
|
||||
XCTAssertEqual(controller.proxyMode, .pac, "新实例未验证时恢复旧模式")
|
||||
XCTAssertEqual(controller.settings.preferredMode, .pac)
|
||||
XCTAssertEqual(settingsStore.saved?.preferredMode, .pac)
|
||||
XCTAssertEqual(try runtimeStore.loadDocument(), previousDocument)
|
||||
XCTAssertNil(runtimeStore.loadDocument()?.aclRuntime)
|
||||
XCTAssertEqual(controller.state, .running, "旧运行时恢复后重新呈现健康")
|
||||
XCTAssertEqual(controller.systemProxyState, .applied)
|
||||
XCTAssertEqual(systemProxy.applied.count, previousApplicationCount)
|
||||
XCTAssertEqual(systemProxy.restoreCount, 0, "切换失败期间保持原系统代理应用")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +464,90 @@ extension RealSslocalSmokeTests {
|
||||
return try readExactly(socketFD, count: payload.count)
|
||||
}
|
||||
|
||||
/// 对公网目标发起 SOCKS5 CONNECT,返回应答码;代理出口不可用时应答非 0
|
||||
/// 也算完成路由观测(不直连真实目标)。
|
||||
private func performSocksConnectReply(
|
||||
socksPort: Int, targetHost: String, targetPort: Int
|
||||
) -> UInt8? {
|
||||
var request: [UInt8] = [0x05, 0x01, 0x00, 0x03, UInt8(targetHost.utf8.count)]
|
||||
request.append(contentsOf: targetHost.utf8)
|
||||
let portBytes = UInt16(targetPort).bigEndian
|
||||
withUnsafeBytes(of: portBytes) { request.append(contentsOf: $0) }
|
||||
return performSocksConnectReply(socksPort: socksPort, request: request)
|
||||
}
|
||||
|
||||
private func performSocksConnectReply(socksPort: Int, request: [UInt8]) -> UInt8? {
|
||||
guard let socketFD = try? connectLoopback(port: socksPort) else { return nil }
|
||||
defer { Darwin.close(socketFD) }
|
||||
do {
|
||||
let greeting: [UInt8] = [0x05, 0x01, 0x00]
|
||||
_ = try greeting.withUnsafeBytes { try writeAll(socketFD, $0) }
|
||||
_ = try readExactly(socketFD, count: 2)
|
||||
_ = try request.withUnsafeBytes { try writeAll(socketFD, $0) }
|
||||
let reply = try readExactly(socketFD, count: 4)
|
||||
return reply[1]
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 按给定 CONNECT 请求完成 SOCKS 会话并回显载荷;`successMessage` 用于
|
||||
/// 断言失败时点名场景。
|
||||
private func performSocksEcho(
|
||||
socksPort: Int, request: [UInt8], payload: [UInt8], successMessage: String
|
||||
) throws -> [UInt8] {
|
||||
let socketFD = try connectLoopback(port: socksPort)
|
||||
defer { Darwin.close(socketFD) }
|
||||
let greeting: [UInt8] = [0x05, 0x01, 0x00]
|
||||
_ = try greeting.withUnsafeBytes { try writeAll(socketFD, $0) }
|
||||
XCTAssertEqual(try readExactly(socketFD, count: 2), [0x05, 0x00])
|
||||
_ = try request.withUnsafeBytes { try writeAll(socketFD, $0) }
|
||||
let reply = try readExactly(socketFD, count: 10)
|
||||
XCTAssertEqual(reply[1], 0, successMessage)
|
||||
_ = try payload.withUnsafeBytes { try writeAll(socketFD, $0) }
|
||||
return try readExactly(socketFD, count: payload.count)
|
||||
}
|
||||
|
||||
private func socksDomainConnectRequest(host: String, port: Int) -> [UInt8] {
|
||||
var request: [UInt8] = [0x05, 0x01, 0x00, 0x03, UInt8(host.utf8.count)]
|
||||
request.append(contentsOf: host.utf8)
|
||||
let portBytes = UInt16(port).bigEndian
|
||||
withUnsafeBytes(of: portBytes) { request.append(contentsOf: $0) }
|
||||
return request
|
||||
}
|
||||
|
||||
/// SOCKS5 CONNECT 到 IPv6 回环(ATYP=0x04)。
|
||||
private func socksIPv6ConnectRequest(port: Int) -> [UInt8] {
|
||||
var request: [UInt8] = [0x05, 0x01, 0x00, 0x04]
|
||||
request.append(contentsOf: Array(repeating: 0, count: 15))
|
||||
request.append(1) // ::1
|
||||
let portBytes = UInt16(port).bigEndian
|
||||
withUnsafeBytes(of: portBytes) { request.append(contentsOf: $0) }
|
||||
return request
|
||||
}
|
||||
|
||||
/// 对公网目标发起 HTTP CONNECT(应答头读到即返回,不要求成功)。
|
||||
private func performHTTPConnect(
|
||||
httpPort: Int, targetHost: String, targetPort: Int
|
||||
) {
|
||||
guard let socketFD = try? connectLoopback(port: httpPort) else { return }
|
||||
defer { Darwin.close(socketFD) }
|
||||
do {
|
||||
let request =
|
||||
"CONNECT \(targetHost):\(targetPort) HTTP/1.1\r\n"
|
||||
+ "Host: \(targetHost):\(targetPort)\r\n\r\n"
|
||||
let requestBytes = Array(request.utf8)
|
||||
_ = try requestBytes.withUnsafeBytes { try writeAll(socketFD, $0) }
|
||||
var response: [UInt8] = []
|
||||
while response.count < 4 || Array(response.suffix(4)) != [13, 10, 13, 10] {
|
||||
response.append(contentsOf: try readExactly(socketFD, count: 1))
|
||||
guard response.count < 4096 else { return }
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func testDirectACLRoutesSOCKSAndHTTPLocallyWithoutServers() throws {
|
||||
let echoServer = try LoopbackEchoServer()
|
||||
var ports = Set<Int>()
|
||||
@@ -533,4 +617,226 @@ extension RealSslocalSmokeTests {
|
||||
XCTAssertEqual(wrapper.terminationStatus, 0)
|
||||
XCTAssertNil(runtimeStore.readRuntimeReceipt(), "停止后应清除运行回执")
|
||||
}
|
||||
|
||||
/// 全局模式 ACL 路由(issue #62):proxy_all + 固定本地绕过。本地目标经
|
||||
/// SOCKS/HTTP 直连(不触 SS 出口),公网目标默认走代理(SS 出口被连接)。
|
||||
/// 两个入站共用同一 ACL;IPv6 系统例外的局限由 ACL 路由兜底(见 ADR)。
|
||||
func testGlobalACLRoutesLocalDirectAndPublicThroughProxyOnSOCKSAndHTTP() throws {
|
||||
let echoServer = try LoopbackEchoServer()
|
||||
let fakeSSServer = try ConnectionCountingServer()
|
||||
var ports = Set<Int>()
|
||||
while ports.count < 3 {
|
||||
let port = try grabEphemeralLoopbackPort()
|
||||
if port != echoServer.port && port != fakeSSServer.port { ports.insert(port) }
|
||||
}
|
||||
let selectedPorts = Array(ports)
|
||||
let listen = SslocalListenSettings(
|
||||
socksPort: selectedPorts[0], httpPort: selectedPorts[1], pacPort: selectedPorts[2])
|
||||
let document = SslocalRuntimeDocument(
|
||||
servers: [
|
||||
SslocalServerDocument(
|
||||
id: "global-smoke-server",
|
||||
remarks: "global-smoke",
|
||||
server: "127.0.0.1",
|
||||
serverPort: fakeSSServer.port,
|
||||
password: "smoke-password",
|
||||
method: "aes-256-gcm",
|
||||
plugin: nil,
|
||||
pluginOpts: nil)
|
||||
],
|
||||
listen: listen,
|
||||
acl: .global(at: workDir.appendingPathComponent("sslocal-active.acl")))
|
||||
XCTAssertTrue(document.isWellFormed)
|
||||
let wrapper = try launchWrapper(document)
|
||||
defer {
|
||||
if wrapper.isRunning { kill(wrapper.processIdentifier, SIGTERM) }
|
||||
}
|
||||
|
||||
try awaitGlobalInboundsReady(listen: listen, document: document)
|
||||
|
||||
try assertLocalTargetsBypass(listen: listen, echoPort: echoServer.port, fakeSS: fakeSSServer)
|
||||
try assertPublicTargetsProxy(listen: listen, fakeSS: fakeSSServer)
|
||||
try assertHostnameAndIPv6Bypass(
|
||||
listen: listen, echoPort: echoServer.port, fakeSS: fakeSSServer)
|
||||
|
||||
kill(wrapper.processIdentifier, SIGTERM)
|
||||
let exited = XCTestExpectation(description: "global wrapper exits")
|
||||
DispatchQueue.global().async {
|
||||
wrapper.waitUntilExit()
|
||||
exited.fulfill()
|
||||
}
|
||||
XCTAssertEqual(XCTWaiter.wait(for: [exited], timeout: 10), .completed)
|
||||
XCTAssertEqual(wrapper.terminationStatus, 0)
|
||||
XCTAssertNil(
|
||||
RuntimeFileStore(fileURL: contractURL).readRuntimeReceipt(),
|
||||
"停止后应清除运行回执")
|
||||
}
|
||||
|
||||
private func awaitGlobalInboundsReady(
|
||||
listen: SslocalListenSettings, document: SslocalRuntimeDocument
|
||||
) throws {
|
||||
XCTAssertTrue(
|
||||
try waitForCondition(timeout: 15) {
|
||||
EndpointHealthProbe.probe(host: "127.0.0.1", port: listen.socksPort, timeout: 1)
|
||||
== .reachable
|
||||
&& EndpointHealthProbe.probe(host: "127.0.0.1", port: listen.httpPort, timeout: 1)
|
||||
== .reachable
|
||||
}, "全局模式应绑定 SOCKS 和 HTTP 入站")
|
||||
let runtimeStore = RuntimeFileStore(fileURL: contractURL)
|
||||
XCTAssertTrue(
|
||||
try waitForCondition(timeout: 30) {
|
||||
guard let receipt = runtimeStore.readRuntimeReceipt() else { return false }
|
||||
return receipt.contractSHA256 == document.deploymentSHA256
|
||||
&& kill(receipt.processID, 0) == 0
|
||||
}, "应发布仍存活的 sslocal 子进程回执")
|
||||
}
|
||||
|
||||
private func assertLocalTargetsBypass(
|
||||
listen: SslocalListenSettings, echoPort: Int, fakeSS: ConnectionCountingServer
|
||||
) throws {
|
||||
let socksPayload = Array("socks local\n".utf8)
|
||||
XCTAssertEqual(
|
||||
try performDirectSocksEcho(
|
||||
socksPort: listen.socksPort, targetPort: echoPort, payload: socksPayload),
|
||||
socksPayload,
|
||||
"全局 ACL 的本地绕过应让 SOCKS 直连回环目标")
|
||||
let httpPayload = Array("http local\n".utf8)
|
||||
XCTAssertEqual(
|
||||
try performDirectHTTPEcho(
|
||||
httpPort: listen.httpPort, targetPort: echoPort, payload: httpPayload),
|
||||
httpPayload,
|
||||
"HTTP 入站应应用同一 ACL 并直连回环目标")
|
||||
XCTAssertEqual(fakeSS.connectionCount, 0, "本地绕过不得触达 Shadowsocks 出口")
|
||||
}
|
||||
|
||||
private func assertPublicTargetsProxy(
|
||||
listen: SslocalListenSettings, fakeSS: ConnectionCountingServer
|
||||
) throws {
|
||||
let publicSocksReply = performSocksConnectReply(
|
||||
socksPort: listen.socksPort, targetHost: "8.8.8.8", targetPort: 53)
|
||||
XCTAssertNotNil(publicSocksReply, "公网目标应走代理路径")
|
||||
XCTAssertTrue(
|
||||
try waitForCondition(timeout: 5) { fakeSS.connectionCount >= 1 },
|
||||
"公网目标应连接 Shadowsocks 出口,而不是直连")
|
||||
let afterSocks = fakeSS.connectionCount
|
||||
|
||||
performHTTPConnect(httpPort: listen.httpPort, targetHost: "1.1.1.1", targetPort: 443)
|
||||
XCTAssertTrue(
|
||||
try waitForCondition(timeout: 5) { fakeSS.connectionCount > afterSocks },
|
||||
"HTTP 入站的公网目标应同样连接 Shadowsocks 出口")
|
||||
XCTAssertTrue(
|
||||
fakeSS.connectionCount >= 2,
|
||||
"SOCKS 与 HTTP 对公网目标遵循同一 ACL 路由")
|
||||
}
|
||||
|
||||
/// 主机名固定绕过与 IPv6 回环(AC2/AC5)。仅 localhost 保证解析到回环 echo;
|
||||
/// 其余主机名与 IPv6 只断言未触达 SS 出口——绕过路由已选定,直连解析失败
|
||||
/// 不改变路由事实,也不依赖不可靠的 IPv6 系统例外。
|
||||
private func assertHostnameAndIPv6Bypass(
|
||||
listen: SslocalListenSettings, echoPort: Int, fakeSS: ConnectionCountingServer
|
||||
) throws {
|
||||
let afterPublic = fakeSS.connectionCount
|
||||
XCTAssertEqual(
|
||||
try performSocksEcho(
|
||||
socksPort: listen.socksPort,
|
||||
request: socksDomainConnectRequest(host: "localhost", port: echoPort),
|
||||
payload: Array("localhost\n".utf8),
|
||||
successMessage: "localhost 应绕过并直连"),
|
||||
Array("localhost\n".utf8))
|
||||
_ = performSocksConnectReply(
|
||||
socksPort: listen.socksPort,
|
||||
request: socksDomainConnectRequest(host: "printer.local", port: echoPort))
|
||||
_ = performSocksConnectReply(
|
||||
socksPort: listen.socksPort,
|
||||
request: socksDomainConnectRequest(host: "nas", port: echoPort))
|
||||
XCTAssertEqual(
|
||||
fakeSS.connectionCount, afterPublic,
|
||||
"localhost/*.local/无点主机名的固定绕过不得触达 Shadowsocks 出口")
|
||||
|
||||
let afterHostnames = fakeSS.connectionCount
|
||||
_ = performSocksConnectReply(
|
||||
socksPort: listen.socksPort,
|
||||
request: socksIPv6ConnectRequest(port: echoPort))
|
||||
XCTAssertEqual(
|
||||
fakeSS.connectionCount, afterHostnames,
|
||||
"IPv6 本地目标不得触达 Shadowsocks 出口,由 ACL 路由兜底")
|
||||
}
|
||||
}
|
||||
|
||||
/// 只接受 TCP 连接并计数的假 SS 服务器:证明目标被路由进代理出口,而非
|
||||
/// 真实完成 Shadowsocks 握手(数据面属发布门槛人工验收)。
|
||||
private final class ConnectionCountingServer {
|
||||
private let descriptor: Int32
|
||||
let port: Int
|
||||
private let counter = CounterBox()
|
||||
|
||||
var connectionCount: Int { counter.value }
|
||||
|
||||
init() throws {
|
||||
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard descriptor >= 0 else { throw POSIXError(.ENOTSOCK) }
|
||||
|
||||
var address = sockaddr_in()
|
||||
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
address.sin_family = sa_family_t(AF_INET)
|
||||
address.sin_addr.s_addr = INADDR_LOOPBACK.bigEndian
|
||||
address.sin_port = 0
|
||||
let bindResult = withUnsafePointer(to: &address) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
|
||||
Darwin.bind(descriptor, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
guard bindResult == 0, Darwin.listen(descriptor, 8) == 0 else {
|
||||
Darwin.close(descriptor)
|
||||
throw POSIXError(.EADDRINUSE)
|
||||
}
|
||||
|
||||
var boundAddress = sockaddr_in()
|
||||
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let nameResult = withUnsafeMutablePointer(to: &boundAddress) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in
|
||||
getsockname(descriptor, sockaddrPointer, &length)
|
||||
}
|
||||
}
|
||||
guard nameResult == 0 else {
|
||||
Darwin.close(descriptor)
|
||||
throw POSIXError(.EINVAL)
|
||||
}
|
||||
|
||||
self.descriptor = descriptor
|
||||
port = Int(CFSwapInt16BigToHost(boundAddress.sin_port))
|
||||
let counter = self.counter
|
||||
DispatchQueue.global().async { [descriptor] in
|
||||
while true {
|
||||
let connection = accept(descriptor, nil, nil)
|
||||
guard connection >= 0 else { return }
|
||||
counter.increment()
|
||||
// 立刻断开:sslocal 的 SS 握手会失败,但 TCP 连接已被观察到。
|
||||
shutdown(connection, SHUT_RDWR)
|
||||
Darwin.close(connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
shutdown(descriptor, SHUT_RDWR)
|
||||
Darwin.close(descriptor)
|
||||
}
|
||||
|
||||
private final class CounterBox: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
var value: Int {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return count
|
||||
}
|
||||
|
||||
func increment() {
|
||||
lock.lock()
|
||||
count += 1
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ final class SystemProxyTests: XCTestCase {
|
||||
target: .pac(URL(string: "http://192.168.2.89:2089/v1/proxy.pac")!)))
|
||||
XCTAssertEqual(
|
||||
try ProxyMode.global.systemProxyConfiguration(for: document),
|
||||
SystemProxyConfiguration(target: .socks(host: "127.0.0.1", port: 2086)))
|
||||
SystemProxyConfiguration(
|
||||
target: .socks(host: "127.0.0.1", port: 2086),
|
||||
exceptions: FixedLocalProxyRanges.systemProxyExceptions),
|
||||
"全局模式的系统例外使用固定本地范围,与 ACL 安全策略一致")
|
||||
}
|
||||
|
||||
func testSystemConfigurationProjectionEnablesOnlyPACOrSOCKS() {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Implement global mode with a minimal ACL
|
||||
|
||||
**Status**: accepted
|
||||
|
||||
## Context
|
||||
|
||||
Global mode previously projected only the system SOCKS endpoint and relied on sslocal's default proxy-everything behaviour. That left local-network targets without a product-owned direct guarantee: LAN-sharing clients, IPv6 destinations whose system-proxy exceptions macOS does not reliably honour, and any request that reaches the SOCKS/HTTP inbounds still needed a defined routing policy. The ACL work (spec #59) requires global mode to mean "public targets default through Shadowsocks, local targets stay direct", using only the fixed local safety rules.
|
||||
|
||||
## Decision
|
||||
|
||||
- Global mode deploys an `sslocal` ACL with `[proxy_all]` and a `[bypass_list]` containing only the fixed local safety rules: IPv4 loopback, RFC 1918, link-local; IPv6 loopback, link-local, and ULA; `localhost`, `*.local`, and no-dot hostnames. No China list, GFWList, or custom rules enter this ACL. CGNAT is excluded.
|
||||
- The same ACL is the routing policy for the local SOCKS inbound, the local HTTP inbound, and LAN-sharing clients that connect to those listeners directly. It is not a substitute for the system proxy exception list; both use the same fixed local scope, but the ACL is the verified bypass for requests that already entered `sslocal`.
|
||||
- IPv6 CIDR strings in the macOS SystemConfiguration exceptions list are not a verified bypass guarantee (only exact IPv6 addresses were observed to match). The ACL's IPv6 rules are the routing guarantee; this limitation is recorded rather than papered over by the exception list.
|
||||
- ACL path, content, digest, and summary are runtime identity, exactly as in ADR-0009. Switching into or out of global mode changes the ACL and therefore requires a full `sslocal` restart through the wrapper — never a bare SIGUSR1 server-list reload. On verification failure the controller restores the previous mode, runtime document, and applied system proxy.
|
||||
- Without a valid active target the agent still listens with an empty server list under the global ACL; local targets remain reachable, public targets have no exit. System proxy intent stays pending and converges automatically once an active target is valid and the local endpoints are healthy.
|
||||
|
||||
## Consequences
|
||||
|
||||
Global mode and direct mode share one fixed local safety policy and one ACL deployment path. Global is no longer "no ACL": it is a minimal `proxy_all` ACL. System proxy writes still require the system proxy switch, agent health, and an available exit (an active target for global). PAC mode remains unchanged until the rules-mode work replaces it.
|
||||
Reference in New Issue
Block a user