mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-31 23:03:43 +00:00
refactor(settings): separate LAN enablement from listener mode
This commit is contained in:
+1
-1
@@ -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 (
|
||||
|
||||
+2
-2
@@ -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 监听。
|
||||
- 保存成功后,运行中的后台代理客户端按需重启;关闭状态下保存不启动客户端。运行失败不回滚保存的配置,代理状态界面报告失败。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user