From 3a6d143b17d91ca94b6c3dc4a272db6e062dea79 Mon Sep 17 00:00:00 2001 From: Qiu Yuzhou Date: Tue, 13 Oct 2026 21:46:04 +0800 Subject: [PATCH] refactor(settings): separate LAN enablement from listener mode --- GLOSSARY.md | 2 +- .../ProxyControlWorkflow.swift | 2 +- .../SettingsWorkflow+LANListening.swift | 4 +- .../SettingsWorkflow/SettingsWorkflow.swift | 3 +- .../SettingsWorkflowModels.swift | 10 ++- .../App/Resources/Localizable.xcstrings | 17 +++++ .../UI/Settings/LANListeningEditorSheet.swift | 51 +++++++------ .../App/UI/Settings/SettingsView.swift | 12 ++- .../Domain/Diagnostics/DiagnosticReport.swift | 3 +- .../Domain/Runtime/PortOccupancyProbe.swift | 2 +- .../Runtime/SslocalListenSettings.swift | 33 ++++++-- .../Runtime/SslocalRuntimeDocument.swift | 8 +- .../ProxyCommandAddressWorkflowTests.swift | 43 ++++++++++- ...ProxyRuntimeControllerTests+Firewall.swift | 7 +- .../Tests/Runtime/SeparateListenerTests.swift | 23 ++++++ .../Settings/LANListeningWorkflowTests.swift | 75 ++++++++++++++++++- .../ListenerModeSettingsWorkflowTests.swift | 7 +- .../Settings/SettingsWorkflowTests.swift | 1 + .../0034-separate-local-and-lan-listeners.md | 15 ++-- 19 files changed, 257 insertions(+), 61 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index 592cd909..dcf09fd6 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -203,7 +203,7 @@ _避免用词_:系统级 UDP 代理、自动 UDP 代理。 局域网 SOCKS 与 HTTP 入口共用的用户名和密码验证,不表示 UDP 中继具备同等保护。 **监听模式(Listener mode)**: -局域网监听的启用状态及地址族选项,包括关闭、所有 IPv4 接口、所有 IPv4 与 IPv6 接口、所有 IPv6 接口。 +局域网监听启用时采用的地址族选项,包括所有 IPv4 接口、所有 IPv4 与 IPv6 接口、所有 IPv6 接口;与是否启用局域网监听独立。 _避免用词_:监听范围(Listen scope)。 **HTTP 代理环境命令(HTTP proxy environment command)**: diff --git a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift index 4790bada..259ce209 100644 --- a/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/ProxyControlWorkflow/ProxyControlWorkflow.swift @@ -280,7 +280,7 @@ final class ProxyControlWorkflow: ObservableObject { ) -> (snapshot: ProxyControlSnapshot, selection: TerminalCommandAddressIdentity) { let listenFacts = runtime.listenFacts let picker = TerminalCommandAddressPolicy.picker( - mode: listenFacts.lan.mode, + mode: listenFacts.lan.effectiveMode, interfaces: interfaceFacts.interfaces, selection: selection) return ( diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift index 93e70c29..9e486b3d 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow+LANListening.swift @@ -18,7 +18,7 @@ extension SettingsWorkflow { return LANListeningDraft( ports: SettingsPortDraft( socksPort: lan.socksPort, httpPort: lan.httpPort, - group: .lan, listenerMode: lan.mode), + group: .lan, listenerMode: lan.mode, isEnabled: lan.isEnabled), udpRelayEnabled: lan.udpRelayEnabled, authenticationEnabled: lan.authenticationEnabled, username: lan.username, password: password) } @@ -58,7 +58,7 @@ extension SettingsWorkflow { private func probeLAN(_ draft: LANListeningDraft, base: ProxySettings) async -> SettingsCommandRejection? { - guard draft.mode != .disabled else { return nil } + guard draft.isEnabled else { return nil } let facts = probeFacts(for: draft.ports) let runtime = committing.runtimeListenFacts let processID = committing.runtimeListenerProcessID diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift index a941b669..a4f21b29 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflow.swift @@ -91,7 +91,7 @@ final class SettingsWorkflow: ObservableObject { portEditorOccupancyByPort = [:] portEditorProbeResults = [:] let facts = probeFacts(for: editorDraft) - if editorDraft.group == .lan && editorDraft.listenerMode == .disabled { + if editorDraft.group == .lan && !editorDraft.isEnabled { portEditorOccupancyByPort = [.socks: .free, .http: .free] return } @@ -214,6 +214,7 @@ final class SettingsWorkflow: ObservableObject { listen.lan.socksPort = editorDraft.socksPort listen.lan.httpPort = editorDraft.httpPort listen.lan.mode = editorDraft.listenerMode + listen.lan.isEnabled = editorDraft.isEnabled } return listen } diff --git a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflowModels.swift b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflowModels.swift index 443db427..47a86413 100644 --- a/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflowModels.swift +++ b/ShadowsocksX-NG2/App/Application/SettingsWorkflow/SettingsWorkflowModels.swift @@ -16,7 +16,8 @@ struct SettingsPortDraft: Equatable, Sendable { var socksPort: Int var httpPort: Int var group: SettingsListeningGroup = .local - var listenerMode: ListenerMode = .disabled + var listenerMode: LANListenerMode = .allIPv4Interfaces + var isEnabled = false func portValue(for id: SettingsPortID) -> Int { switch id { @@ -130,7 +131,12 @@ struct LANListeningDraft: Equatable, Sendable { && RuntimeListenerAuthentication.validPassword(password) } - var mode: ListenerMode { + var isEnabled: Bool { + get { ports.isEnabled } + set { ports.isEnabled = newValue } + } + + var mode: LANListenerMode { get { ports.listenerMode } set { ports.listenerMode = newValue } } diff --git a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings index 2bbd586f..0841e39d 100644 --- a/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings +++ b/ShadowsocksX-NG2/App/Resources/Localizable.xcstrings @@ -1448,6 +1448,23 @@ } } }, + "启用局域网监听": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用局域网监听" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enable LAN Listeners" + } + } + } + }, "局域网监听": { "extractionState": "manual", "localizations": { diff --git a/ShadowsocksX-NG2/App/UI/Settings/LANListeningEditorSheet.swift b/ShadowsocksX-NG2/App/UI/Settings/LANListeningEditorSheet.swift index 3d418db9..88e22852 100644 --- a/ShadowsocksX-NG2/App/UI/Settings/LANListeningEditorSheet.swift +++ b/ShadowsocksX-NG2/App/UI/Settings/LANListeningEditorSheet.swift @@ -22,36 +22,39 @@ struct LANListeningEditorSheet: View { Text("局域网监听").font(.title2) ScrollView { Form { - Picker("监听方式", selection: $draft.mode) { - ForEach(ListenerMode.allCases, id: \.self) { mode in - VStack(alignment: .leading, spacing: 2) { - Text(mode.displayName) - if mode != .disabled { + Toggle("启用局域网监听", isOn: $draft.isEnabled) + + Group { + Picker("监听方式", selection: $draft.mode) { + ForEach(LANListenerMode.allCases, id: \.self) { mode in + VStack(alignment: .leading, spacing: 2) { + Text(mode.displayName) Text(mode.bindingHint).font(.caption).foregroundStyle(.secondary) - } - }.tag(mode) + }.tag(mode) + } } - } - .pickerStyle(.radioGroup) + .pickerStyle(.radioGroup) - Divider() + Divider() - Section { - ListenerPortFields(workflow: workflow, draft: $draft.ports) - } footer: { - Text("端口范围为 1025–65535") + Section { + ListenerPortFields(workflow: workflow, draft: $draft.ports) + } footer: { + Text("端口范围为 1025–65535") + .font(.caption).foregroundStyle(.secondary) + } + + Divider() + + Toggle("SOCKS UDP 中继", isOn: $draft.udpRelayEnabled) + Text("当前的身份验证实现无法可靠保护 UDP 中继。") .font(.caption).foregroundStyle(.secondary) + + Divider() + + authenticationFields } - - Divider() - - Toggle("SOCKS UDP 中继", isOn: $draft.udpRelayEnabled) - Text("当前的身份验证实现无法可靠保护 UDP 中继。") - .font(.caption).foregroundStyle(.secondary) - - Divider() - - authenticationFields + .disabled(!draft.isEnabled) } .padding(4) } diff --git a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift index 0fd19b25..3c7e2e64 100644 --- a/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift +++ b/ShadowsocksX-NG2/App/UI/Settings/SettingsView.swift @@ -96,10 +96,14 @@ struct SettingsView: View { HStack(spacing: 8) { VStack(alignment: .trailing, spacing: 3) { let lan = workflow.committedLANListening - Text("\(lan.mode.displayName) SOCKS \(lan.socksPort) / HTTP \(lan.httpPort)") - Text(lanSummary) - .font(.caption) - .foregroundStyle(.secondary) + if lan.isEnabled { + Text("\(lan.mode.displayName) SOCKS \(lan.socksPort) / HTTP \(lan.httpPort)") + Text(lanSummary) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("关闭") + } } Image(systemName: "chevron.right").font(.caption.weight(.semibold)) } diff --git a/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift b/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift index eeb67bf5..7fd56a85 100644 --- a/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift +++ b/ShadowsocksX-NG2/Domain/Diagnostics/DiagnosticReport.swift @@ -199,8 +199,9 @@ enum DiagnosticReportBuilder { lines.append("- 活动目标:\(snapshot.hasActiveTarget ? "已设置" : "未设置")") if let listen = snapshot.listen { let binding = listen.lan.isEnabled ? "(\(listen.lan.mode.bindAddress))" : "" + let lanMode = listen.lan.isEnabled ? listen.lan.mode.displayName : String(localized: "关闭") lines.append( - "- 局域网监听:\(listen.lan.mode.displayName)\(binding),SOCKS \(listen.lan.socksPort) / HTTP \(listen.lan.httpPort)" + "- 局域网监听:\(lanMode)\(binding),SOCKS \(listen.lan.socksPort) / HTTP \(listen.lan.httpPort)" ) lines.append("- 本地监听:\(listenDescription(listen))") } diff --git a/ShadowsocksX-NG2/Domain/Runtime/PortOccupancyProbe.swift b/ShadowsocksX-NG2/Domain/Runtime/PortOccupancyProbe.swift index 69c125a8..04c7b543 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/PortOccupancyProbe.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/PortOccupancyProbe.swift @@ -137,7 +137,7 @@ struct PortOccupancyProbeRequest: Equatable, Sendable { var listenerMode: ListenerMode { switch endpoint { case .socks, .http: listen.listenerMode - case .lanSocks, .lanHTTP: listen.lan.mode + case .lanSocks, .lanHTTP: listen.lan.effectiveMode } } diff --git a/ShadowsocksX-NG2/Domain/Runtime/SslocalListenSettings.swift b/ShadowsocksX-NG2/Domain/Runtime/SslocalListenSettings.swift index 40d0089c..3fffa823 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/SslocalListenSettings.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/SslocalListenSettings.swift @@ -13,7 +13,8 @@ struct LocalListeningSettings: Codable, Equatable, Sendable { /// 网络共享入口与本机入口共存,凭据只携带 Keychain 引用。 struct LANListeningSettings: Codable, Equatable, Sendable { - var mode: ListenerMode = .disabled + var isEnabled = false + var mode: LANListenerMode = .allIPv4Interfaces var socksPort = 11088 var httpPort = 11089 var udpRelayEnabled = false @@ -21,7 +22,26 @@ struct LANListeningSettings: Codable, Equatable, Sendable { var username = "user" var passwordReference: String? - var isEnabled: Bool { mode != .disabled } + var effectiveMode: ListenerMode { isEnabled ? mode.bindingMode : .disabled } +} + +/// 局域网地址族配置;关闭时仍保留所选方式。 +enum LANListenerMode: String, Codable, CaseIterable, Equatable, Sendable { + case allIPv4Interfaces = "all_ipv4_interfaces" + case allIPv4AndIPv6Interfaces = "all_ipv4_and_ipv6_interfaces" + case allIPv6Interfaces = "all_ipv6_interfaces" + + var bindingMode: ListenerMode { + switch self { + case .allIPv4Interfaces: .allIPv4Interfaces + case .allIPv4AndIPv6Interfaces: .allIPv4AndIPv6Interfaces + case .allIPv6Interfaces: .allIPv6Interfaces + } + } + + var bindAddress: String { bindingMode.bindAddress } + var bindingHint: String { bindingMode.bindingHint } + var displayName: String { bindingMode.displayName } } struct SslocalListenSettings: Equatable, Sendable { @@ -36,12 +56,15 @@ struct SslocalListenSettings: Equatable, Sendable { socksPort: Int = Self.defaultSocksPort, httpPort: Int = Self.defaultHTTPPort ) { local = LocalListeningSettings(socksPort: socksPort, httpPort: httpPort) - lan.mode = listenerMode + self.listenerMode = listenerMode } var listenerMode: ListenerMode { - get { lan.mode } - set { lan.mode = newValue } + get { lan.effectiveMode } + set { + lan.isEnabled = newValue != .disabled + if let mode = LANListenerMode(rawValue: newValue.rawValue) { lan.mode = mode } + } } var socksPort: Int { get { local.socksPort } diff --git a/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift b/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift index e9594c13..44eb3b5b 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/SslocalRuntimeDocument.swift @@ -1,6 +1,8 @@ import Foundation -/// Address-family policy for the optional LAN SOCKS and HTTP inbounds. +/// Effective binding policy used by runtime documents and port probes. +/// `disabled` represents the local loopback binding; LAN settings use +/// `LANListenerMode` and an independent enable switch. enum ListenerMode: String, Codable, CaseIterable, Equatable, Sendable { case disabled case allIPv4Interfaces = "all_ipv4_interfaces" @@ -241,8 +243,8 @@ extension SslocalRuntimeDocument { aclRuntime.map({ $0.isWellFormed && $0.path == aclFilePath }) ?? true else { return false } - guard listen.bindAddress == "127.0.0.1", listen.listenerMode == listen.lan.mode, - ipv6Only == listen.lan.mode.ipv6Only + guard listen.bindAddress == "127.0.0.1", listen.listenerMode == listen.lan.effectiveMode, + ipv6Only == listen.lan.effectiveMode.ipv6Only else { return false } let local = locals.filter { $0.localAddress == "127.0.0.1" } let network = locals.filter { $0.localAddress != "127.0.0.1" } diff --git a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift index 621a9881..1345b169 100644 --- a/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyControl/ProxyCommandAddressWorkflowTests.swift @@ -1,8 +1,37 @@ +import Testing import XCTest @testable import ShadowsocksX_NG2 -/// 首页命令地址选择器 contract。候选派生:四种监听方式的地址族 +@MainActor +struct LANCommandEnablementTests { + @Test func disablingLANRetainsIPv6ModeAndFallsBackFromSelectedNetworkAddress() { + let runtime = FakeProxyRuntime() + let interfaces = FakeLocalInterfaceFacts() + let workflow = ProxyControlWorkflow( + runtime: runtime, targetFacts: FakeTargetFacts(), interfaceFacts: interfaces) + runtime.listenFacts.lan.isEnabled = true + runtime.listenFacts.lan.mode = .allIPv6Interfaces + interfaces.interfaces = [ + FakeLocalInterfaceFacts.wifi(addresses: [.ipv6("2001:db8::1")]) + ] + runtime.emitChange() + let address = workflow.snapshot.commandAddressPicker.candidates[1] + _ = workflow.selectCommandAddress(address) + #expect(workflow.snapshot.commandAddressPicker.selected.address == "2001:db8::1") + runtime.listenFacts.lan.isEnabled = false + runtime.emitChange() + #expect(runtime.listenFacts.lan.mode == .allIPv6Interfaces) + let picker = workflow.snapshot.commandAddressPicker + #expect(!picker.isVisible) + #expect(picker.candidates.map(\.address) == ["127.0.0.1"]) + #expect(picker.selected.address == "127.0.0.1") + #expect( + workflow.snapshot.terminalProxyEnvironmentCommands.zshBash.contains("http://127.0.0.1:11087")) + } +} + +/// 首页命令地址选择器 contract。候选派生:启用状态与三种监听方式的地址族 /// 过滤与默认值、严格接口类型过滤、名称回退、排序合并与网络变化刷新。选择 /// 生命周期与命令生成见 `ProxyCommandAddressLifecycleTests`。复用 /// ProxyControlWorkflowTests 的可编程替身。 @@ -24,7 +53,10 @@ final class ProxyCommandAddressWorkflowTests: XCTestCase { private func setListenerMode(_ mode: ListenerMode) { runtime.listenFacts = RuntimeListenFacts( listenerMode: .disabled, socksPort: 11086, httpPort: 11087) - runtime.listenFacts.lan.mode = mode + runtime.listenFacts.lan.isEnabled = mode != .disabled + if let lanMode = LANListenerMode(rawValue: mode.rawValue) { + runtime.listenFacts.lan.mode = lanMode + } runtime.emitChange() } @@ -196,13 +228,17 @@ final class ProxyCommandAddressLifecycleTests: XCTestCase { private func setListenerMode(_ mode: ListenerMode) { runtime.listenFacts = RuntimeListenFacts( listenerMode: .disabled, socksPort: 11086, httpPort: 11087) - runtime.listenFacts.lan.mode = mode + runtime.listenFacts.lan.isEnabled = mode != .disabled + if let lanMode = LANListenerMode(rawValue: mode.rawValue) { + runtime.listenFacts.lan.mode = lanMode + } runtime.emitChange() } func testSelectionDrivesBothShellsAndSavedPorts() { runtime.listenFacts = RuntimeListenFacts( listenerMode: .disabled, socksPort: 11086, httpPort: 11087) + runtime.listenFacts.lan.isEnabled = true runtime.listenFacts.lan.mode = .allIPv4Interfaces runtime.listenFacts.lan.socksPort = 12086 runtime.listenFacts.lan.httpPort = 12087 @@ -352,6 +388,7 @@ final class ProxyCommandAddressLifecycleTests: XCTestCase { func testCopyPreparationRefreshesFallsBackAndMatchesProjection() { runtime.listenFacts = RuntimeListenFacts( listenerMode: .disabled, socksPort: 11086, httpPort: 11087) + runtime.listenFacts.lan.isEnabled = true runtime.listenFacts.lan.mode = .allIPv4Interfaces runtime.emitChange() interfaceFacts.interfaces = [ diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests+Firewall.swift b/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests+Firewall.swift index d46602ab..3deafe22 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests+Firewall.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntime/ProxyRuntimeControllerTests+Firewall.swift @@ -11,15 +11,18 @@ final class ProxyRuntimeFirewallTests: ProxyRuntimeControllerTests { func testLocalhostModeNeverQueriesApplicationFirewall() async throws { let seeded = try makeSeededCatalog() let firewall = ProxyRuntimeFixture.FakeFirewallChecker(.blocked) + var listen = SslocalListenSettings() + listen.lan.mode = .allIPv6Interfaces let controller = makeController( - probe: ProxyRuntimeFixture.FakeProbe.reachable(), firewallChecker: firewall) + probe: ProxyRuntimeFixture.FakeProbe.reachable(), listen: listen, firewallChecker: firewall) try await controller.activate(seeded.server) await controller.setAgentEnabled(true) XCTAssertEqual(controller.state, .running) XCTAssertTrue(firewall.checkedURLs.isEmpty, "回环态与应用防火墙零交互") - XCTAssertEqual(controller.effectiveRuntimeListenFacts?.lan.mode, .disabled) + XCTAssertEqual(controller.effectiveRuntimeListenFacts?.lan.isEnabled, false) + XCTAssertEqual(controller.effectiveRuntimeListenFacts?.lan.mode, .allIPv6Interfaces) } func testAllIPv4InterfacesBlockedByFirewallPresentsTargetedRepair() async throws { diff --git a/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift b/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift index d34a8a7a..ee2abca9 100644 --- a/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift +++ b/ShadowsocksX-NG2/Tests/Runtime/SeparateListenerTests.swift @@ -4,6 +4,29 @@ import Testing @testable import ShadowsocksX_NG2 struct SeparateListenerTests { + @Test func disabledIPv6ConfigurationRoundTripsWithoutAffectingEffectiveRuntime() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = ListenSettingsFileStore(fileURL: directory.appendingPathComponent("listen.json")) + var listen = SslocalListenSettings() + listen.lan.mode = .allIPv6Interfaces + listen.lan.authenticationEnabled = true + listen.lan.passwordReference = "saved-reference" + try store.save(listen) + let restored = try store.load() + #expect(!restored.lan.isEnabled) + #expect(restored.lan.mode == .allIPv6Interfaces) + #expect(restored.lan.passwordReference == "saved-reference") + let document = SslocalRuntimeDocument(servers: [], listen: restored) + #expect(document.locals.count == 2) + #expect(document.ipv6Only == nil) + #expect(document.isWellFormed) + #expect(SslocalRuntimeDocument.decodeValidated(try document.jsonData()) != nil) + #expect( + PortOccupancyProbeRequest(endpoint: .socks, listen: RuntimeListenFacts(listen: restored)) + .bindAddress == "127.0.0.1") + } + @Test func enablingLANPreservesLocalListeners() { var listen = SslocalListenSettings() listen.listenerMode = .allIPv4Interfaces diff --git a/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift index b6a198da..3d8762b0 100644 --- a/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/LANListeningWorkflowTests.swift @@ -5,6 +5,67 @@ import Testing @MainActor struct LANListeningWorkflowTests { + @Test func togglingLANInvalidatesOccupancyWithoutChangingModeOrPorts() async throws { + let probe = FakeOccupancyProbe(occupiedPorts: [11088]) + let workflow = SettingsWorkflow( + committing: FakeSettingsCommitter(), occupancyProbe: probe, + credentials: InMemoryCredentialStore()) + var draft = try workflow.beginLANListeningEditing() + draft.isEnabled = true + workflow.refreshPortEditorOccupancy(for: draft.ports) + await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft.ports).occupancy != nil) + #expect(!workflow.canSaveLANListening(draft)) + + draft.isEnabled = false + #expect(workflow.portFieldState(for: .socks, editorDraft: draft.ports).occupancy == nil) + workflow.refreshPortEditorOccupancy(for: draft.ports) + #expect(workflow.canSaveLANListening(draft)) + let count = probe.callCount + + draft.isEnabled = true + #expect(workflow.portFieldState(for: .socks, editorDraft: draft.ports).occupancy == nil) + #expect(!workflow.canSaveLANListening(draft)) + workflow.refreshPortEditorOccupancy(for: draft.ports) + await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft.ports).occupancy != nil) + #expect(probe.callCount == count + 2) + #expect(!workflow.canSaveLANListening(draft)) + } + + @Test func disablingAndReenablingLANPreservesModePortsAndCredentials() async throws { + let committing = FakeSettingsCommitter() + let probe = FakeOccupancyProbe() + let workflow = SettingsWorkflow( + committing: committing, occupancyProbe: probe, + credentials: InMemoryCredentialStore()) + var draft = try workflow.beginLANListeningEditing() + #expect(!draft.isEnabled) + #expect(draft.mode == .allIPv4Interfaces) + draft.isEnabled = true + draft.mode = .allIPv6Interfaces + draft.ports.socksPort = 13001 + draft.udpRelayEnabled = true + draft.authenticationEnabled = true + draft.password = "saved-password" + #expect(await workflow.saveLANListening(draft) == .persisted) + + draft.isEnabled = false + let probeCount = probe.callCount + #expect(await workflow.saveLANListening(draft) == .persisted) + #expect(probe.callCount == probeCount) + #expect(committing.committedSettings.listen.locals.count == 2) + var reopened = try workflow.beginLANListeningEditing() + #expect(!reopened.isEnabled) + #expect(reopened.mode == .allIPv6Interfaces) + #expect(reopened.ports.socksPort == 13001) + #expect(reopened.udpRelayEnabled) + #expect(reopened.authenticationEnabled) + #expect(reopened.password == "saved-password") + reopened.isEnabled = true + #expect(await workflow.saveLANListening(reopened) == .persisted) + #expect(committing.committedSettings.listen.locals.count == 4) + #expect(committing.committedSettings.listen.locals.last?.localAddress == "::") + } + @Test func savingDisabledLANKeepsLocalPortsAndSkipsOccupancy() async throws { let committing = FakeSettingsCommitter() committing.committedSettings.listen.socksPort = 13001 @@ -63,6 +124,7 @@ struct LANListeningWorkflowTests { committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) var draft = try workflow.beginLANListeningEditing() + draft.isEnabled = true draft.mode = .allIPv4Interfaces let submission = Task { await workflow.saveLANListening(draft) } await waitUntil(probe.entered > 0) @@ -74,26 +136,29 @@ struct LANListeningWorkflowTests { } @Test(arguments: [ - ListenerMode.allIPv4Interfaces, .allIPv4AndIPv6Interfaces, .allIPv6Interfaces, + LANListenerMode.allIPv4Interfaces, .allIPv4AndIPv6Interfaces, .allIPv6Interfaces, ]) - func editorProbesPreserveBothGroupsAndUseLANEndpoints(mode: ListenerMode) async throws { + func editorProbesPreserveBothGroupsAndUseLANEndpoints(mode: LANListenerMode) async throws { let committing = FakeSettingsCommitter() let probe = FakeOccupancyProbe() let workflow = SettingsWorkflow( committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) var draft = try workflow.beginLANListeningEditing() + draft.isEnabled = true draft.mode = mode draft.ports.socksPort = 13001 workflow.refreshPortEditorOccupancy(for: draft.ports) await waitUntil(workflow.portFieldState(for: .http, editorDraft: draft.ports).occupancy != nil) var expected = RuntimeListenFacts(listen: committing.committedSettings.listen) + expected.lan.isEnabled = true expected.lan.mode = mode expected.lan.socksPort = 13001 #expect(probe.requests.count == 2) #expect(probe.requests.map(\.endpoint) == [.lanSocks, .lanHTTP]) #expect(probe.requests.map(\.port) == [13001, 11089]) - #expect(probe.requests.allSatisfy { $0.listen == expected && $0.listenerMode == mode }) + #expect( + probe.requests.allSatisfy { $0.listen == expected && $0.listenerMode == mode.bindingMode }) #expect(probe.requests.allSatisfy { $0.bindAddress == mode.bindAddress }) #expect(await workflow.saveLANListening(draft) == .persisted) @@ -110,6 +175,7 @@ struct LANListeningWorkflowTests { let workflow = SettingsWorkflow( committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) var draft = try workflow.beginLANListeningEditing() + draft.isEnabled = true draft.mode = .allIPv6Interfaces workflow.refreshPortEditorOccupancy(for: draft.ports) await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft.ports).occupancy != nil) @@ -145,6 +211,7 @@ struct LANListeningWorkflowTests { @Test func ownLANPortExceptionUsesLANRuntimePortAndMode() async throws { let committing = FakeSettingsCommitter() + committing.committedSettings.listen.lan.isEnabled = true committing.committedSettings.listen.lan.mode = .allIPv4AndIPv6Interfaces committing.runtimeListenFacts = RuntimeListenFacts(listen: committing.committedSettings.listen) committing.runtimeListenerProcessID = 123 @@ -153,6 +220,7 @@ struct LANListeningWorkflowTests { let workflow = SettingsWorkflow( committing: committing, occupancyProbe: probe, credentials: InMemoryCredentialStore()) var draft = try workflow.beginLANListeningEditing() + draft.isEnabled = true draft.mode = .allIPv4Interfaces workflow.refreshPortEditorOccupancy(for: draft.ports) await waitUntil(workflow.portFieldState(for: .socks, editorDraft: draft.ports).occupancy != nil) @@ -162,6 +230,7 @@ struct LANListeningWorkflowTests { @Test func matchingPIDCannotExemptAChangedLANPort() async throws { let committing = FakeSettingsCommitter() + committing.committedSettings.listen.lan.isEnabled = true committing.committedSettings.listen.lan.mode = .allIPv4Interfaces committing.runtimeListenFacts = RuntimeListenFacts(listen: committing.committedSettings.listen) committing.runtimeListenerProcessID = 123 diff --git a/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift index 7802cee3..ed29c855 100644 --- a/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/ListenerModeSettingsWorkflowTests.swift @@ -4,12 +4,13 @@ import XCTest @MainActor extension SettingsWorkflowInterfaceTests { - private func saveMode(_ workflow: SettingsWorkflow, _ mode: ListenerMode) async + private func saveMode(_ workflow: SettingsWorkflow, _ mode: LANListenerMode?) async -> SettingsCommandOutcome { do { var draft = try workflow.beginLANListeningEditing() - draft.mode = mode + draft.isEnabled = mode != nil + if let mode { draft.mode = mode } return await workflow.saveLANListening(draft) } catch { return .persistenceFailed(.unknown) } } @@ -55,7 +56,7 @@ extension SettingsWorkflowInterfaceTests { committing.runtimeListenerProcessID = 42 let workflow = makeWorkflow() - let outcome = await saveMode(workflow, .disabled) + let outcome = await saveMode(workflow, nil) XCTAssertEqual(outcome, .persisted) XCTAssertEqual(committing.committedSettings.listen.listenerMode, .disabled) diff --git a/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift b/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift index 1c349769..b3d2bc46 100644 --- a/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/Settings/SettingsWorkflowTests.swift @@ -33,6 +33,7 @@ final class SettingsWorkflowTests: XCTestCase { let pair = makePair() var draft = try pair.workflow.beginLANListeningEditing() + draft.isEnabled = true draft.mode = .allIPv4AndIPv6Interfaces let outcome = await pair.workflow.saveLANListening(draft) diff --git a/docs/adr/0034-separate-local-and-lan-listeners.md b/docs/adr/0034-separate-local-and-lan-listeners.md index b9502c7e..7220a8e2 100644 --- a/docs/adr/0034-separate-local-and-lan-listeners.md +++ b/docs/adr/0034-separate-local-and-lan-listeners.md @@ -2,34 +2,39 @@ **状态**:已接受。 +2026-10-13 修订:局域网启用状态与监听模式独立。 + 现有单组监听将本机使用与网络设备共享耦合在同一对端口上。改为始终提供本地入口,并允许独立开启局域网入口,使局域网的地址族、身份验证和 UDP 开关不改变本机入口。 ## 已确认的决策 - 后台代理客户端运行时,本地 SOCKS 与 HTTP 始终在 `127.0.0.1` 监听;不增加本地监听开关。 - 本地默认端口为 SOCKS `11086`、HTTP `11087`;本地 SOCKS 固定 `tcp_and_udp`,HTTP 固定 `tcp_only`。 -- 局域网默认端口为 SOCKS `11088`、HTTP `11089`;默认关闭。 -- 局域网监听模式为关闭、所有 IPv4 接口、所有 IPv4 与 IPv6 接口、所有 IPv6 接口。后三项分别绑定 `0.0.0.0`、`::` 配合 `ipv6_only=false`、`::` 配合 `ipv6_only=true`。 +- 局域网默认端口为 SOCKS `11088`、HTTP `11089`;启用状态为独立 Bool 开关,默认关闭。 +- 局域网监听模式为所有 IPv4 接口、所有 IPv4 与 IPv6 接口、所有 IPv6 接口,默认所有 IPv4 接口。三项分别绑定 `0.0.0.0`、`::` 配合 `ipv6_only=false`、`::` 配合 `ipv6_only=true`。关闭不覆盖已选监听模式,重新启用沿用所保存的模式。 - 四个端口限制为 `1025...65535`,且全部互异;关闭局域网监听时也校验这些配置约束。 - 局域网 SOCKS UDP 默认关闭,用户可以开启;表单说明身份验证无法可靠保护 UDP 中继。 - 仅检查 TCP 端口占用。局域网关闭时不检查其端口占用。配置不显式指定 UDP 端口,使用 sslocal 的默认行为。 - 局域网 SOCKS 与 HTTP 共用一组用户名和密码;身份验证默认关闭。 - 首次编辑预填用户名 `user`,并生成 10 位小写字母和数字密码。未保存的密码随取消操作丢弃;保存后不重新生成。 - 用户名和密码均为 1–255 字节的可打印 ASCII;用户名不得包含 `:`;不自动删除首尾空格。 -- 身份验证关闭时保留凭据,并禁用凭据输入框。局域网关闭时保留全部配置,并禁用其余三部分。 +- 身份验证关闭时保留凭据,并禁用凭据输入框。局域网关闭时保留全部配置,并禁用监听方式、端口、UDP、身份验证与凭据控件;取消与保存仍按原有规则可用。 - 密码保存在现有 Keychain 凭据存储中。保存失败保留旧配置;复制命令时读取失败停止复制并显示错误。运行时认证文件仅允许当前用户读写,配置摘要不包含密码。wrapper 隐藏上游认证失败日志中客户端提交的密码;超长日志丢弃到下一个换行,防止分块输入泄露尾部。 - 设置使用“本地监听”和“局域网监听”两个入口,每项独立 sheet;每个 sheet 的完整值独立保存。 +- 局域网 sheet 切换启用开关只修改草稿;保存成功后才改变实际监听。关闭草稿时保留本次尚未保存的所有修改,重新启用恢复编辑并重新检查 TCP 端口占用;过期探测结果不能影响保存。取消丢弃本次全部修改。 +- 外层局域网设置项始终显示已保存的状态;关闭时只显示“关闭”,不显示监听方式、端口、UDP 或身份验证详情。 - 界面使用 `SOCKS`、`HTTP 代理端口`、`所有 IPv6 接口`。 - 首页默认命令地址为 `127.0.0.1`,使用本地端口且不加入凭据。其他候选来自现有 Wi-Fi/Ethernet 地址策略,按局域网模式过滤;选择这些地址时使用局域网端口,必要时加入凭据。 +- 局域网关闭时不提供局域网命令地址候选,原局域网地址选择回退 `127.0.0.1`,不执行局域网防火墙观察;所保留的监听模式不影响有效本地入口。 - 首页移除 `::1` 候选。地址失效时回退 `127.0.0.1`;选择仍仅属于当前会话。 - URL 特殊字符编码后再生成 shell 命令;tooltip 与复制内容都包含完整命令和真实密码。 -- NG2 尚未正式发版,不迁移旧监听配置,新监听使用默认值。 +- NG2 尚未正式发版,不迁移旧监听配置,新安装使用默认值;本次独立启用开关调整不要求兼容旧局域网记录。 ## Module 设计 - 扩展现有监听 Module:`SslocalListenSettings` 包含本地与局域网的明确类型;负责四端口校验和有效入口派生,不引入任意数量的通用监听数组模型。 - 复用 `SettingsWorkflow` 的独立编辑与保存 Interface。认证文件格式和凭据提交留在现有持久化与运行配置 Implementation 内,UI 不拼接运行时 JSON。 -- 旧监听字段与旧监听文件不再作为新监听的输入;其他偏好与服务器资料保留。 +- 从单组监听改为本地与局域网独立监听时,旧监听字段与旧监听文件不再作为新监听的输入;其他偏好与服务器资料保留。 - 系统代理、健康检查的本机目标、菜单 HTTP 环境命令始终使用本地入口;运行时同时检查所有启用入口是否建立 TCP 监听。 - 保存成功后,运行中的后台代理客户端按需重启;关闭状态下保存不启动客户端。运行失败不回滚保存的配置,代理状态界面报告失败。