diff --git a/CONTEXT.md b/CONTEXT.md index 32ac4262..98de1d90 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -50,7 +50,7 @@ - Imported mode and target preferences never start proxying or write system proxy settings. Legacy passwords, plugin options, and user-provided remote URLs move to credential references; generated configurations, caches, diagnostics, logs, and binaries do not migrate. The imported plugin program reference is preserved verbatim even when 2.0 does not provide that plugin. - A Legacy handoff is an explicit second phase, not an import side effect. It may stop only positively identified Legacy runtime jobs after confirmation and must not broadly kill processes or automatically delete Legacy data. - 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. +- 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 proxying is on, and neither depends on the GUI being open. The GUI writes the system proxy configuration and gates that write on endpoint health. - Proxy modes are mutually exclusive: PAC writes the runtime PAC URL, global writes only the local SOCKS proxy, manual restores the user's prior system proxy state, and external PAC writes a validated HTTP/HTTPS URL. System proxy writes are allowed only after endpoint health; 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. - 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/ProxyRuntimeController.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift index c32357ce..c5f3352e 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController.swift @@ -47,6 +47,9 @@ final class ProxyRuntimeController: ObservableObject { private let credentials: CredentialStoring private let plugins: ManagedPluginProviding private let listen: SslocalListenSettings + /// 监听设置不可读时的点名原因(D8「任何路径不静默改端口」);非 nil 时 + /// `listen` 只是占位出厂默认,禁止部署(见 `deploy`)。 + private let listenUnreadableReason: String? private let agent: LaunchAgentControlling private let probe: EndpointProbing private let pacProbe: PACHealthProbing @@ -71,7 +74,7 @@ final class ProxyRuntimeController: ObservableObject { runtimeFileStore: RuntimeFileStore = RuntimeFileStore(), credentials: CredentialStoring = KeychainCredentialStore(), plugins: ManagedPluginProviding = NoManagedPluginProvider(), - listen: SslocalListenSettings = SslocalListenSettings(), + listenRestore: RestoredListenSettings = ListenSettingsFileStore.restored(), agent: LaunchAgentControlling = SMAppLaunchAgentService(), probe: EndpointProbing = SystemEndpointProbe(), pacProbe: PACHealthProbing = SystemPACHealthProbe(), @@ -87,7 +90,8 @@ final class ProxyRuntimeController: ObservableObject { self.runtimeFileStore = runtimeFileStore self.credentials = credentials self.plugins = plugins - self.listen = listen + self.listen = listenRestore.settings + listenUnreadableReason = listenRestore.unreadableError?.presentedReason self.agent = agent self.probe = probe self.pacProbe = pacProbe @@ -275,6 +279,10 @@ final class ProxyRuntimeController: ObservableObject { /// 把「运行定义档」意图推到运行时:计划动作 → 顺序执行 → 端点健康呈现。 private func deploy(_ document: SslocalRuntimeDocument) async { + if let reason = listenUnreadableReason { + await refuseDeployForUnreadableListenSettings(reason) + return + } pacURL = nil lastDocument = document if await execute(.run(document), document: document) { @@ -283,6 +291,23 @@ final class ProxyRuntimeController: ObservableObject { } } + /// D8「任何路径不静默改端口」:监听设置不可读时以占位出厂端口部署等于 + /// 系统擅自改端口——停止运行时并点名呈现,等用户在设置区修复(#33 接线)。 + private func refuseDeployForUnreadableListenSettings(_ reason: String) async { + RuntimeLog.emit(.activationFailed(reason: reason)) + let restoreError = restoreSystemProxyError() + _ = await execute(.stop, document: nil) + pacURL = nil + lastDocument = nil + let detail = + "本地代理端口配置无法读取(\(reason)),已停止代理以避免静默改用出厂端口;请在设置区修复端口后重新启动" + if let restoreError { + state = .launchFailed(detail: "\(detail);\(systemProxyDetail(restoreError))") + } else { + state = .launchFailed(detail: detail) + } + } + private func handleCleared(_ failure: ActivationFailure) async { RuntimeLog.emit(.activationFailed(reason: failure.presentedReason)) do { diff --git a/ShadowsocksX-NG2/Domain/ListenSettingsFileStore.swift b/ShadowsocksX-NG2/Domain/ListenSettingsFileStore.swift new file mode 100644 index 00000000..4ee8a41a --- /dev/null +++ b/ShadowsocksX-NG2/Domain/ListenSettingsFileStore.swift @@ -0,0 +1,162 @@ +import Foundation + +enum ListenSettingsStoreError: Error, Equatable { + /// 文件存在但不是可接受的记录:JSON 损坏或字段缺失。 + case corrupt(detail: String) + /// 记录可解码但端口配置无效(范围或互异校验失败);点名全部原因。 + case invalidPorts([PortSettingError]) + /// 读取或写入时的文件系统错误。 + case ioFailure(detail: String) + + var presentedReason: String { + switch self { + case .corrupt: + return "监听设置文件损坏" + case .invalidPorts(let errors): + return errors.map(\.presentedReason).joined(separator: ";") + case .ioFailure: + return "监听设置文件读取失败" + } + } +} + +protocol ListenSettingsStoring { + /// 文件缺失 → 出厂默认(出厂状态,不是改写用户配置);损坏或无效 → 抛错。 + func load() throws -> SslocalListenSettings + /// 保存前先做端口校验,被拒绝的保存不触碰已落盘文件。 + func save(_ settings: SslocalListenSettings) throws +} + +/// 应用启动的监听设置恢复结果。`unreadableError` 非 nil 表示用户配置存在但 +/// 不可读(损坏、无效或 I/O 失败),此时 `settings` 只是占位出厂默认—— +/// 消费方不得静默以占位端口运行(D8「任何路径不静默改端口」),必须点名 +/// 呈现并停止代理,等待用户修复。 +struct RestoredListenSettings: Equatable { + let settings: SslocalListenSettings + let unreadableError: ListenSettingsStoreError? +} + +/// 用户监听设置(端口、HTTP 启用、UDP、监听范围)的磁盘持久化,落盘 +/// `~/Library/Application Support/ShadowsocksX-NG/listen-settings.json`。 +/// 只承载用户显式确认过的配置;与 v2/ 运行时契约文件分离。监听范围只按 +/// 原样保留,有效性由派生文档的读取侧校验兜底。 +struct ListenSettingsFileStore: ListenSettingsStoring { + private static let jsonEncoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + }() + + let fileURL: URL + + static func defaultFileURL() -> URL { + let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[ + 0] + return support.appendingPathComponent("ShadowsocksX-NG/listen-settings.json") + } + + init(fileURL: URL = ListenSettingsFileStore.defaultFileURL()) { + self.fileURL = fileURL + } + + func load() throws -> SslocalListenSettings { + guard FileManager.default.fileExists(atPath: fileURL.path) else { + return SslocalListenSettings() + } + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch { + throw ListenSettingsStoreError.ioFailure(detail: String(describing: error)) + } + let record: ListenSettingsRecord + do { + record = try JSONDecoder().decode(ListenSettingsRecord.self, from: data) + } catch { + throw ListenSettingsStoreError.corrupt(detail: String(describing: error)) + } + let settings = Self.settings(from: record) + let validationErrors = settings.portValidationErrors() + guard validationErrors.isEmpty else { + throw ListenSettingsStoreError.invalidPorts(validationErrors) + } + return settings + } + + func save(_ settings: SslocalListenSettings) throws { + let validationErrors = settings.portValidationErrors() + guard validationErrors.isEmpty else { + throw ListenSettingsStoreError.invalidPorts(validationErrors) + } + let data: Data + do { + data = try Self.jsonEncoder.encode(Self.record(from: settings)) + } catch { + throw ListenSettingsStoreError.ioFailure(detail: String(describing: error)) + } + do { + try AtomicFileWriter.write(data, to: fileURL) + } catch { + throw ListenSettingsStoreError.ioFailure(detail: String(describing: error)) + } + } + + /// 应用启动的恢复入口:文件缺失是出厂状态;用户配置不可读时记录事件并 + /// 携带点名错误返回占位出厂默认(消费方据此停止代理并呈现,不静默运行 + /// 在占位端口上;设置编辑呈现面在 #33)。 + static func restored() -> RestoredListenSettings { + do { + return RestoredListenSettings(settings: try Self().load(), unreadableError: nil) + } catch let error as ListenSettingsStoreError { + RuntimeLog.emit(.listenSettingsUnreadable(detail: String(describing: error))) + return RestoredListenSettings( + settings: SslocalListenSettings(), unreadableError: error) + } catch { + RuntimeLog.emit(.listenSettingsUnreadable(detail: String(describing: error))) + return RestoredListenSettings( + settings: SslocalListenSettings(), + unreadableError: .ioFailure(detail: String(describing: error))) + } + } +} + +/// 持久化记录只由本存储读写;测试以原始 JSON 夹具覆盖解码契约。 +private struct ListenSettingsRecord: Codable, Equatable, Sendable { + var scopeKind: ListenScopeKind = .loopback + /// 主机态的对外公布地址;回环态为 nil。 + var advertisedAddress: String? + var socksPort: Int = 1086 + var httpProxyEnabled: Bool = true + var httpPort: Int = 1087 + var pacPort: Int = 1089 + var udpRelayEnabled: Bool = false +} + +extension ListenSettingsFileStore { + private static func record(from settings: SslocalListenSettings) -> ListenSettingsRecord { + var record = ListenSettingsRecord() + record.scopeKind = settings.scope.kind + if case .host(let address) = settings.scope { + record.advertisedAddress = address + } + record.socksPort = settings.socksPort + record.httpProxyEnabled = settings.httpProxyEnabled + record.httpPort = settings.httpPort + record.pacPort = settings.pacPort + record.udpRelayEnabled = settings.udpRelayEnabled + return record + } + + private static func settings(from record: ListenSettingsRecord) -> SslocalListenSettings { + var settings = SslocalListenSettings() + if record.scopeKind == .host, let address = record.advertisedAddress { + settings.scope = .host(advertisedAddress: address) + } + settings.socksPort = record.socksPort + settings.httpProxyEnabled = record.httpProxyEnabled + settings.httpPort = record.httpPort + settings.pacPort = record.pacPort + settings.udpRelayEnabled = record.udpRelayEnabled + return settings + } +} diff --git a/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift b/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift new file mode 100644 index 00000000..13c8df87 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/PortOccupancyProbe.swift @@ -0,0 +1,77 @@ +import Darwin +import Foundation + +/// 端口占用判定结果。空闲与占用之外还有「无法判定」:绑定测试自身失败 +/// (地址不可解析、特权端口等)时如实呈现,不冒充空闲。 +enum PortOccupancy: Equatable, Sendable { + case free + /// 端口不可绑定;占用进程名为尽力解析(未知为 nil,D8「尽力而为」)。 + case occupied(occupier: String?) + case unknown(detail: String) +} + +/// 端口占用探测缝(issue #30):设置编辑期的即时占用校验。激活是否成功 +/// 仍以 runtime 实际绑定为准(#29 健康门禁),本探测不构成权威判定。 +protocol PortOccupancyProbing: Sendable { + func occupancy(port: Int, bindAddress: String) -> PortOccupancy +} + +/// 系统实现:对 `bindAddress:port` 做一次不带复用选项的 TCP bind——能绑定 +/// 即空闲,`EADDRINUSE` 即占用;占用时尽力以 `lsof` 解析监听进程名。探测 +/// 不设置 SO_REUSEADDR/SO_REUSEPORT,避免 Legacy 式复用残留的假空闲。 +struct SystemPortOccupancyProbe: PortOccupancyProbing { + func occupancy(port: Int, bindAddress: String) -> PortOccupancy { + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(port).bigEndian + guard inet_pton(AF_INET, bindAddress, &address.sin_addr) == 1 else { + return .unknown(detail: "监听地址无法解析") + } + let descriptor = socket(AF_INET, SOCK_STREAM, 0) + guard descriptor >= 0 else { + return .unknown(detail: String(cString: strerror(errno))) + } + defer { close(descriptor) } + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + if bindResult == 0 { return .free } + if errno == EADDRINUSE { + return .occupied(occupier: Self.occupierProcessName(port: port)) + } + return .unknown(detail: String(cString: strerror(errno))) + } + + /// `lsof` 尽力解析监听进程名:本产品代理进程与 GUI 同用户运行,无需特权; + /// lsof 缺失、失败或无匹配时返回 nil,不影响占用判定本身。 + static func occupierProcessName(port: Int) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") + process.arguments = ["-nP", "-iTCP:\(port)", "-sTCP:LISTEN"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return occupierProcessName(fromLsofOutput: String(data: data, encoding: .utf8) ?? "") + } + + /// 从 lsof 输出解析第一个数据行的进程名(首字段);表头与空行跳过。 + static func occupierProcessName(fromLsofOutput output: String) -> String? { + for line in output.split(whereSeparator: \.isNewline) { + let fields = line.split( + maxSplits: 1, omittingEmptySubsequences: true, whereSeparator: \.isWhitespace) + guard let name = fields.first, name != "COMMAND" else { continue } + return String(name) + } + return nil + } +} diff --git a/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift b/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift new file mode 100644 index 00000000..be490bb3 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/ProxyPortSemantics.swift @@ -0,0 +1,104 @@ +/// 本地代理端点的端口语义(spec #21 D8,issue #30):端口是显式用户配置, +/// 出厂默认沿用 Legacy(SOCKS5 1086 / HTTP 1087 / PAC 1089);系统任何路径 +/// 都不静默改端口——建议端口只产出候选,必须经用户确认保存后才生效;激活 +/// 是否成功以 runtime 实际绑定为准(健康门禁),本层的占用校验只是设置编辑 +/// 期尽力而为的即时提示。 + +/// 三个本地代理端点;显示名与 D8 及诊断报告的措辞一致。 +enum ProxyEndpointKind: Equatable, Sendable, CaseIterable { + case socks + case http + case pac + + var displayName: String { + switch self { + case .socks: "SOCKS5" + case .http: "HTTP" + case .pac: "PAC" + } + } +} + +/// 端口配置校验错误:点名端点与端口(D8 错误信息要求)。 +enum PortSettingError: Error, Equatable, Sendable { + case portOutOfRange(endpoint: ProxyEndpointKind, port: Int) + /// 两个端点的配置端口相同;`endpoint` 在枚举序(socks → http → pac)中在前。 + case duplicatePort(endpoint: ProxyEndpointKind, otherEndpoint: ProxyEndpointKind, port: Int) + + var presentedReason: String { + switch self { + case .portOutOfRange(let endpoint, let port): + return "\(endpoint.displayName) 端口 \(port) 无效,必须是 1–65535 之间的整数" + case .duplicatePort(let endpoint, let otherEndpoint, let port): + return + "\(endpoint.displayName) 端口与 \(otherEndpoint.displayName) 端口冲突(都是 \(port)),请为每个端点配置不同的端口" + } + } +} + +/// 建议空闲端口算法(D8):32768–65535 高位段升序扫描,跳过排除集合与不 +/// 空闲端口。空闲判定由调用方注入,保证主缝可测。 +enum PortSuggestion { + static let range = 32768...65535 + + static func firstFree(excluding excludedPorts: Set, isFree: (Int) -> Bool) -> Int? { + for candidate in range where !excludedPorts.contains(candidate) { + if isFree(candidate) { return candidate } + } + return nil + } +} + +extension SslocalListenSettings { + /// 三个端点的配置端口(与 HTTP 是否启用无关:配置值一律互异,避免日后 + /// 重新启用 HTTP 时才暴露冲突)。 + func configuredPort(for endpoint: ProxyEndpointKind) -> Int { + switch endpoint { + case .socks: socksPort + case .http: httpPort + case .pac: pacPort + } + } + + /// 端口配置校验:范围 1–65535、三端点配置值两两互异。错误按固定次序 + /// (socks/http/pac 范围检查,再 socks-http、socks-pac、http-pac 冲突)。 + func portValidationErrors() -> [PortSettingError] { + var errors: [PortSettingError] = [] + for endpoint in ProxyEndpointKind.allCases { + let port = configuredPort(for: endpoint) + if !(1...65535).contains(port) { + errors.append(.portOutOfRange(endpoint: endpoint, port: port)) + } + } + for pair in Self.endpointPairs { + let first = configuredPort(for: pair.0) + if first == configuredPort(for: pair.1) { + errors.append(.duplicatePort(endpoint: pair.0, otherEndpoint: pair.1, port: first)) + } + } + return errors + } + + /// 为 `endpoint` 建议一个空闲端口:高位段升序首个空闲且不等于另两个端点 + /// 配置值的候选;整段无可用返回 nil。只产出候选,绝不替用户改配置。 + func suggestedPort(for endpoint: ProxyEndpointKind, isFree: (Int) -> Bool) -> Int? { + let others = Set(ProxyEndpointKind.allCases.filter { $0 != endpoint }.map(configuredPort)) + return PortSuggestion.firstFree(excluding: others, isFree: isFree) + } + + private static let endpointPairs: [(ProxyEndpointKind, ProxyEndpointKind)] = [ + (.socks, .http), (.socks, .pac), (.http, .pac), + ] +} + +/// PAC 端口变更的失效提示(D8):已分享出去的 PAC URL 内嵌端口,换端口即 +/// 失效,保存前必须提示。UI 呈现面在 #33 接线。 +enum PortChangeNotice { + static func pacInvalidation( + from previous: SslocalListenSettings, to next: SslocalListenSettings + ) -> String? { + guard previous.pacPort != next.pacPort else { return nil } + return + "PAC 端口将从 \(previous.pacPort) 改为 \(next.pacPort),已分享的 PAC URL 将失效,保存后需要重新分享" + } +} diff --git a/ShadowsocksX-NG2/Domain/RuntimeLog.swift b/ShadowsocksX-NG2/Domain/RuntimeLog.swift index c08cbe31..fb4da6fd 100644 --- a/ShadowsocksX-NG2/Domain/RuntimeLog.swift +++ b/ShadowsocksX-NG2/Domain/RuntimeLog.swift @@ -36,6 +36,9 @@ enum RuntimeLogEvent: CustomStringConvertible, Sendable { case activationFailed(reason: String) /// 用户显式触发的脱敏诊断导出已完成(不携带导出路径)。 case diagnosticsExported + /// 监听设置持久化读取失败,已回落出厂默认(D8:不静默改端口——事件进 + /// 诊断日志供 #34 查看器呈现,完整呈现面在 #33)。 + case listenSettingsUnreadable(detail: String) var description: String { switch self { @@ -83,6 +86,8 @@ enum RuntimeLogEvent: CustomStringConvertible, Sendable { return "activation failed: \(reason)" case .diagnosticsExported: return "diagnostics report exported (redacted)" + case .listenSettingsUnreadable(let detail): + return "listen settings unreadable, falling back to factory defaults: \(detail)" } } } diff --git a/ShadowsocksX-NG2/Tests/ListenSettingsFileStoreTests.swift b/ShadowsocksX-NG2/Tests/ListenSettingsFileStoreTests.swift new file mode 100644 index 00000000..43bd8c3c --- /dev/null +++ b/ShadowsocksX-NG2/Tests/ListenSettingsFileStoreTests.swift @@ -0,0 +1,111 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 监听设置持久化(spec #21 D8,issue #30):用户改过的端口必须跨启动保留 +/// 且任何读取路径不得静默改写——文件缺失回落出厂默认,损坏或端口无效一律 +/// 点名报错交由上层呈现,绝不静默修正。 +final class ListenSettingsFileStoreTests: XCTestCase { + private var directory: URL! + private var store: ListenSettingsFileStore! + + override func setUpWithError() throws { + try super.setUpWithError() + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("ssxng-tests-\(UUID().uuidString)", isDirectory: true) + store = ListenSettingsFileStore( + fileURL: directory.appendingPathComponent("listen-settings.json")) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: directory) + try super.tearDownWithError() + } + + private func permissions(of url: URL) throws -> Int { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + return try XCTUnwrap(attributes[.posixPermissions] as? Int) + } + + private func writeRaw(_ text: String) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try Data(text.utf8).write(to: store.fileURL) + } + + func testLoadWithoutFileReturnsFactoryDefaultsAndCreatesNothing() throws { + let settings = try store.load() + + XCTAssertEqual(settings, SslocalListenSettings(), "文件缺失即出厂状态,非静默改端口") + XCTAssertFalse(FileManager.default.fileExists(atPath: store.fileURL.path)) + } + + func testSaveAndLoadRoundTripPreservesUserPortsAndScope() throws { + var settings = SslocalListenSettings() + settings.scope = .host(advertisedAddress: "192.168.2.89") + settings.socksPort = 2086 + settings.httpProxyEnabled = false + settings.httpPort = 2087 + settings.pacPort = 2089 + settings.udpRelayEnabled = true + + try store.save(settings) + + XCTAssertEqual(try store.load(), settings) + XCTAssertEqual(try permissions(of: directory), 0o700) + XCTAssertEqual(try permissions(of: store.fileURL), 0o600) + } + + func testSaveRejectsInvalidPortsWithoutTouchingExistingFile() throws { + try store.save(SslocalListenSettings()) + + var invalid = SslocalListenSettings() + invalid.socksPort = 70000 + XCTAssertThrowsError(try store.save(invalid)) { error in + XCTAssertEqual( + error as? ListenSettingsStoreError, + .invalidPorts([.portOutOfRange(endpoint: .socks, port: 70000)])) + } + XCTAssertEqual( + try store.load(), + SslocalListenSettings(), + "被拒绝的保存不得改动已落盘的用户配置") + } + + func testLoadReportsCorruptFileInsteadOfSilentlyRewritingPorts() throws { + try writeRaw("{not json") + + XCTAssertThrowsError(try store.load()) { error in + guard case .corrupt = error as? ListenSettingsStoreError else { + return XCTFail("损坏文件必须点名报错,实际 \(error)") + } + } + } + + func testLoadDistinguishesReadFailureFromCorruption() throws { + try writeRaw("{}") + try FileManager.default.setAttributes( + [.posixPermissions: 0o000], ofItemAtPath: store.fileURL.path) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o600], ofItemAtPath: store.fileURL.path) + } + + XCTAssertThrowsError(try store.load()) { error in + guard case .ioFailure = error as? ListenSettingsStoreError else { + return XCTFail("读取失败必须与损坏区分,实际 \(error)") + } + } + } + + func testLoadRejectsPersistedDuplicatePorts() throws { + try writeRaw( + #"{"scopeKind":"loopback","advertisedAddress":null,"socksPort":1086,"# + + #""httpProxyEnabled":true,"httpPort":1086,"pacPort":1089,"udpRelayEnabled":false}"#) + + XCTAssertThrowsError(try store.load()) { error in + XCTAssertEqual( + error as? ListenSettingsStoreError, + .invalidPorts([.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086)])) + } + } +} diff --git a/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift b/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift new file mode 100644 index 00000000..4ab84030 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/ProxyPortSemanticsTests.swift @@ -0,0 +1,199 @@ +import Darwin +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 端口语义(spec #21 D8,issue #30)主缝测试:出厂默认、占用校验、建议端口 +/// 算法与点名错误。bind 探测覆盖真实回环 socket;lsof 输出解析为纯函数夹具。 +final class ProxyPortSemanticsTests: XCTestCase { + // MARK: 出厂默认(D8:沿用 Legacy,任何路径不静默改端口) + + func testFactoryDefaultsInheritLegacyPortsAndValidateClean() { + let settings = SslocalListenSettings() + + XCTAssertEqual(settings.socksPort, 1086) + XCTAssertEqual(settings.httpPort, 1087) + XCTAssertEqual(settings.pacPort, 1089) + XCTAssertTrue(settings.portValidationErrors().isEmpty) + } + + // MARK: 配置校验与点名错误 + + func testOutOfRangePortsAreRejectedNamingEndpointAndPort() { + var settings = SslocalListenSettings() + settings.socksPort = 0 + settings.pacPort = 65536 + + XCTAssertEqual( + settings.portValidationErrors(), + [ + .portOutOfRange(endpoint: .socks, port: 0), + .portOutOfRange(endpoint: .pac, port: 65536), + ]) + } + + func testDuplicateConfiguredPortsAreRejectedNamingBothEndpoints() { + let settings = SslocalListenSettings( + socksPort: 1086, + httpProxyEnabled: false, + httpPort: 1086, + pacPort: 1089) + + XCTAssertEqual( + settings.portValidationErrors(), + [.duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086)], + "HTTP 停用时其配置端口仍参与互异校验,避免日后重新启用时才暴露冲突") + } + + func testPresentedReasonsNameEndpointAndPort() { + XCTAssertEqual( + PortSettingError.portOutOfRange(endpoint: .http, port: 70000).presentedReason, + "HTTP 端口 70000 无效,必须是 1–65535 之间的整数") + XCTAssertEqual( + PortSettingError.duplicatePort(endpoint: .pac, otherEndpoint: .socks, port: 1086) + .presentedReason, + "PAC 端口与 SOCKS5 端口冲突(都是 1086),请为每个端点配置不同的端口") + } + + // MARK: 建议端口算法(D8:32768–65535 高位段,空闲且与另两端点配置值互异) + + func testSuggestionScansHighRangeAscendingSkippingExcludedAndOccupied() { + var probed: [Int] = [] + let suggested = PortSuggestion.firstFree( + excluding: [32768], + isFree: { port in + probed.append(port) + return port != 32769 + }) + + XCTAssertEqual(suggested, 32770) + XCTAssertEqual(probed, [32769, 32770], "被排除的 32768 不探测;占用端口跳过") + } + + func testSuggestionReturnsNilWhenWholeRangeExhausted() { + XCTAssertNil(PortSuggestion.firstFree(excluding: [], isFree: { _ in false })) + } + + func testSuggestedPortForEndpointExcludesOtherTwoConfiguredValues() { + let settings = SslocalListenSettings( + socksPort: 40000, httpProxyEnabled: false, httpPort: 40001, pacPort: 1089) + var probed: [Int] = [] + let suggested = settings.suggestedPort(for: .pac) { port in + probed.append(port) + return port == 40002 + } + + XCTAssertEqual(suggested, 40002) + XCTAssertFalse(probed.contains(40000)) + XCTAssertFalse(probed.contains(40001), "另两端点的配置值无论启用与否都排除") + } + + // MARK: 占用探测(设置区即时校验的 I/O 缝;激活仍以 runtime 实际绑定的 + // 健康门禁为准,本探测只是编辑期尽力而为的提示) + + func testBindProbeReportsOccupiedThenFreeOnRealLoopbackSocket() throws { + let held = try ListenerFixture() + let port = held.port + let probe = SystemPortOccupancyProbe() + + guard case .occupied = probe.occupancy(port: port, bindAddress: "127.0.0.1") else { + return XCTFail("已被本测试进程监听的端口必须判为占用") + } + + held.close() + + guard case .free = probe.occupancy(port: port, bindAddress: "127.0.0.1") else { + return XCTFail("关闭监听后同一端口必须判为空闲") + } + } + + func testBindProbeReportsUnknownForUnresolvableAddress() { + let outcome = SystemPortOccupancyProbe().occupancy(port: 1086, bindAddress: "not-an-ip") + + guard case .unknown = outcome else { + return XCTFail("地址不可解析时不得冒充空闲或占用,实际 \(outcome)") + } + } + + func testOccupierNameParsedFromLsofOutputFixture() { + let output = """ + COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME + sslocal 12345 qiu 7u IPv4 0xabcd 0t0 TCP 127.0.0.1:1086 (LISTEN) + TestApp 12346 qiu 8u IPv4 0xabce 0t0 TCP 127.0.0.1:1086 (LISTEN) + """ + XCTAssertEqual(SystemPortOccupancyProbe.occupierProcessName(fromLsofOutput: output), "sslocal") + XCTAssertNil(SystemPortOccupancyProbe.occupierProcessName(fromLsofOutput: " \n")) + XCTAssertNil( + SystemPortOccupancyProbe.occupierProcessName(fromLsofOutput: "COMMAND PID USER FD"), + "只有表头(无占用进程)时解析为 nil") + } + + // MARK: PAC 端口变更失效提示(D8;UI 面在 #33 接线) + + func testPACPortChangeProducesInvalidationNoticeNamingBothPorts() { + let notice = PortChangeNotice.pacInvalidation( + from: SslocalListenSettings(), to: SslocalListenSettings(pacPort: 8080)) + + XCTAssertEqual( + notice, + "PAC 端口将从 1089 改为 8080,已分享的 PAC URL 将失效,保存后需要重新分享") + } + + func testChangesNotTouchingPACPortProduceNoNotice() { + XCTAssertNil( + PortChangeNotice.pacInvalidation( + from: SslocalListenSettings(), to: SslocalListenSettings(socksPort: 2086))) + XCTAssertNil( + PortChangeNotice.pacInvalidation( + from: SslocalListenSettings(), to: SslocalListenSettings())) + } +} + +/// 测试专用回环监听器:占用一个内核分配端口,供占用探测断言真实占用。 +private final class ListenerFixture { + let descriptor: Int32 + let port: Int + + init() throws { + let listenerFD = socket(AF_INET, SOCK_STREAM, 0) + guard listenerFD >= 0 else { throw POSIXError(.ENOTSOCK) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(listenerFD, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + let error = errno + Darwin.close(listenerFD) + throw POSIXError(POSIXErrorCode(rawValue: error)!) + } + guard listen(listenerFD, 1) == 0 else { + let error = errno + Darwin.close(listenerFD) + throw POSIXError(POSIXErrorCode(rawValue: error)!) + } + var bound = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(listenerFD, $0, &length) + } + } + guard nameResult == 0 else { + let error = errno + Darwin.close(listenerFD) + throw POSIXError(POSIXErrorCode(rawValue: error)!) + } + descriptor = listenerFD + port = Int(CFSwapInt16BigToHost(bound.sin_port)) + } + + func close() { + Darwin.close(descriptor) + } +} diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift index 99ddb311..7616e1a6 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests.swift @@ -80,7 +80,7 @@ final class ProxyRuntimeControllerTests: XCTestCase { runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), credentials: credentials, plugins: ActivationFixture.plugins, - listen: listen, + listenRestore: RestoredListenSettings(settings: listen, unreadableError: nil), agent: agent, probe: probe, pacProbe: pacProbe, diff --git a/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift b/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift new file mode 100644 index 00000000..9b8eff46 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/UnreadableListenSettingsTests.swift @@ -0,0 +1,101 @@ +import XCTest + +@testable import ShadowsocksX_NG2 + +/// 监听设置不可读时的控制器行为(spec #21 D8「任何路径不静默改端口」, +/// issue #30):占位出厂端口不得部署——停止运行时并点名呈现启动失败。 +@MainActor +final class UnreadableListenSettingsTests: XCTestCase { + private var runtime: ProxyRuntimeFixture.TemporaryV2! + private var catalogFileURL: URL! + private var activationFileURL: URL! + private var credentials: InMemoryCredentialStore! + private var agent: ProxyRuntimeFixture.FakeLaunchAgent! + private var systemProxy: ProxyRuntimeFixture.FakeSystemProxy! + + override func setUpWithError() throws { + try super.setUpWithError() + runtime = ProxyRuntimeFixture.makeTemporaryV2() + catalogFileURL = runtime.directory.appendingPathComponent("catalog.json") + activationFileURL = runtime.directory.appendingPathComponent("activation.json") + credentials = InMemoryCredentialStore() + agent = ProxyRuntimeFixture.FakeLaunchAgent() + systemProxy = ProxyRuntimeFixture.FakeSystemProxy() + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: runtime.directory) + try super.tearDownWithError() + } + + private func makeController( + agentStatus: LaunchAgentStatus, + listenUnreadable: ListenSettingsStoreError + ) -> ProxyRuntimeController { + agent.setStatus(agentStatus) + return ProxyRuntimeController( + catalogFileStore: CatalogFileStore(fileURL: catalogFileURL), + activationFileStore: ActivationStateFileStore(fileURL: activationFileURL), + runtimeFileStore: RuntimeFileStore(fileURL: runtime.contract), + credentials: credentials, + plugins: ActivationFixture.plugins, + listenRestore: RestoredListenSettings( + settings: SslocalListenSettings(), unreadableError: listenUnreadable), + agent: agent, + probe: ProxyRuntimeFixture.FakeProbe.reachable(), + systemProxy: systemProxy, + sendSignal: { _, _ in 0 }) + } + + /// 建一个含单台服务器的目录并落盘(服务器密码进内存凭据存储)。 + private func makeSeededCatalog() throws -> NodeID { + var catalog = ConfigurationCatalog() + let server = try ActivationFixture.addPlainServer( + "香港 01", in: &catalog, credentials: credentials) + try CatalogFileStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) + return server + } + + func testEnableWithUnreadableListenSettingsRefusesDeploymentAndNamesReason() async throws { + let server = try makeSeededCatalog() + let controller = makeController( + agentStatus: .notRegistered, + listenUnreadable: .invalidPorts([ + .duplicatePort(endpoint: .socks, otherEndpoint: .http, port: 1086) + ])) + + await controller.activate(server) + await controller.setProxyEnabled(true) + + guard case .launchFailed(let detail) = controller.state else { + return XCTFail("应呈现启动失败,实际 \(controller.state)") + } + XCTAssertTrue( + detail.contains("端口配置无法读取") && detail.contains("1086"), + "失败必须点名监听设置不可读及原因:\(detail)") + XCTAssertEqual(agent.registerCount, 0, "占位出厂端口不得部署") + XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.contract.path)) + XCTAssertTrue(systemProxy.applied.isEmpty, "占位端口不得写系统代理") + } + + func testResyncWithUnreadableListenSettingsStopsRunningAgentInsteadOfRedeploying() async throws { + let server = try makeSeededCatalog() + try ActivationStateFileStore(fileURL: activationFileURL).save(activeTargetID: server) + try RuntimeFileStore(fileURL: runtime.contract).write(ProxyRuntimeFixture.makeDocument()) + let controller = makeController( + agentStatus: .registered, + listenUnreadable: .corrupt(detail: "test")) + + await controller.resyncOnLaunch() + + guard case .launchFailed(let detail) = controller.state else { + return XCTFail("应呈现启动失败,实际 \(controller.state)") + } + XCTAssertTrue(detail.contains("端口配置无法读取"), "必须点名监听设置不可读:\(detail)") + XCTAssertEqual( + agent.unregisterCount, 1, "存续的上一会话 agent 必须停下,不得以占位端口重部署") + XCTAssertFalse(FileManager.default.fileExists(atPath: runtime.contract.path)) + XCTAssertTrue(systemProxy.applied.isEmpty) + XCTAssertNil(controller.pacURL) + } +}