refactor(settings): separate LAN enablement from listener mode

This commit is contained in:
Qiu Yuzhou
2026-10-13 21:46:04 +08:00
parent c61cfe2cab
commit 3a6d143b17
19 changed files with 257 additions and 61 deletions
+1 -1
View File
@@ -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)**:
@@ -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 (
@@ -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
@@ -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
}
@@ -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 }
}
@@ -1448,6 +1448,23 @@
}
}
},
"启用局域网监听": {
"extractionState": "manual",
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用局域网监听"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Enable LAN Listeners"
}
}
}
},
"局域网监听": {
"extractionState": "manual",
"localizations": {
@@ -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)
}
@@ -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))
}
@@ -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))")
}
@@ -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
}
}
@@ -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 }
@@ -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" }
@@ -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 = [
@@ -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 {
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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 监听。
- 保存成功后,运行中的后台代理客户端按需重启;关闭状态下保存不启动客户端。运行失败不回滚保存的配置,代理状态界面报告失败。