diff --git a/CONTEXT.md b/CONTEXT.md index 5691ff78..c861ea75 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,7 +32,7 @@ - **Listener mode**: The user-facing choice that determines which address families the local proxy listeners accept: this Mac only (IPv4 loopback), all IPv4 interfaces, all IPv4 and IPv6 interfaces, or all IPv6 interfaces only. One mode applies to both the local SOCKS and HTTP proxy inbounds. _Avoid_: Listen scope. - **HTTP proxy environment command**: An HTTP-only terminal command that sets lowercase `http_proxy` and `https_proxy` for this Mac's local HTTP proxy inbound. It always targets a loopback address for the selected listener mode. - **Terminal proxy environment commands**: Shell-specific commands for zsh/bash and fish that set lowercase `http_proxy` and `https_proxy` through the local HTTP inbound, `all_proxy` through the local SOCKS inbound, and `no_proxy` for `localhost`, `127.0.0.1`, `::1`, and `.local` hostnames. The proxy endpoints use the terminal command address (defaulting to a compatible loopback address), and the commands affect child processes that honor these variables. -- **Terminal command address**: The user-selected IP address used by both HTTP and SOCKS endpoints in copied terminal proxy environment commands. It defaults to a compatible loopback address; non-local listener modes also offer addresses of enabled Wi-Fi and Ethernet interfaces in the accepted address families, and the selection lasts only for the current app session without changing listener or system-proxy settings. +- **Terminal command address**: The user-selected IP address used by both HTTP and SOCKS endpoints in copied terminal proxy environment commands. It defaults to a compatible loopback address; non-local listener modes also offer addresses of enabled Wi-Fi and Ethernet interfaces in the accepted address families, excluding rotating temporary addresses, and the selection lasts only for the current app session without changing listener or system-proxy settings. - **Proxy runtime**: The per-user background host process, independent of the GUI's lifetime, that runs the external tunnel service and loads the optional generated ACL sidecar. - **Legacy configuration**: The server list and preferences persisted by the frozen implementation in `Legacy/`. - **Legacy import**: The user-confirmed, read-only translation of a Legacy configuration's server records into a separate manual group in 2.0. It migrates no preferences, activates no target, and neither starts proxying nor alters the Legacy source. diff --git a/ShadowsocksX-NG2/App/ProxyControlWorkflow/LocalInterfaceFacts.swift b/ShadowsocksX-NG2/App/ProxyControlWorkflow/LocalInterfaceFacts.swift index 3118c0f7..10950377 100644 --- a/ShadowsocksX-NG2/App/ProxyControlWorkflow/LocalInterfaceFacts.swift +++ b/ShadowsocksX-NG2/App/ProxyControlWorkflow/LocalInterfaceFacts.swift @@ -3,8 +3,22 @@ import Foundation // MARK: - 原始接口事实(issue #72) -/// 单个本机接口单播地址的原始事实:族与 IPv6 链路本地标记已判明,过滤、 -/// 排序与取舍全部由工作流层策略完成。 +/// IPv6 地址的内核标志(经 `SIOCGIFAFLAG_IN6` 逐地址查询,netinet6/in6_var.h +/// 的 `IN6_IFF_*` 子集)。仅 IPv6 单播地址携带;IPv4 或查询失败为 nil。 +struct LocalInterfaceV6Flags: OptionSet, Sendable { + let rawValue: Int32 + /// `IN6_IFF_AUTOCONF`:SLAAC 自动配置地址。 + static let autoconf = Self(rawValue: IN6_IFF_AUTOCONF) + /// `IN6_IFF_TEMPORARY`:RFC 4941 隐私临时地址(会轮换)。 + static let temporary = Self(rawValue: IN6_IFF_TEMPORARY) + /// `IN6_IFF_SECURED`:稳定接口标识生成的地址。 + static let secured = Self(rawValue: IN6_IFF_SECURED) + /// `IN6_IFF_DYNAMIC`:DHCPv6 分配的地址。 + static let dynamic = Self(rawValue: IN6_IFF_DYNAMIC) +} + +/// 单个本机接口单播地址的原始事实:族与 IPv6 标记已判明,过滤、排序与 +/// 取舍全部由工作流层策略完成。 struct LocalInterfaceAddress: Equatable, Sendable { enum Family: Equatable, Sendable { case ipv4 @@ -17,6 +31,9 @@ struct LocalInterfaceAddress: Equatable, Sendable { /// IPv6 链路本地(fe80::/10);IPv4 链路本地不置位(属于允许接口的单播 /// 地址时可以列出)。 let isIPv6LinkLocal: Bool + /// IPv6 地址内核标志(含临时地址、DHCPv6 等类型事实);IPv4 或查询失败 + /// 为 nil——nil 不触发过滤,也不注记。 + let v6Flags: LocalInterfaceV6Flags? } /// 单个本机接口的原始事实(issue #72):系统适配器逐接口提供,类型白名单、 diff --git a/ShadowsocksX-NG2/App/ProxyControlWorkflow/SystemInterfaceFactsProvider.swift b/ShadowsocksX-NG2/App/ProxyControlWorkflow/SystemInterfaceFactsProvider.swift index ef21b275..885fe886 100644 --- a/ShadowsocksX-NG2/App/ProxyControlWorkflow/SystemInterfaceFactsProvider.swift +++ b/ShadowsocksX-NG2/App/ProxyControlWorkflow/SystemInterfaceFactsProvider.swift @@ -61,6 +61,15 @@ final class SystemInterfaceFactsProvider: LocalInterfaceFactsReading { guard getifaddrs(&interfacesPtr) == 0, let first = interfacesPtr else { return nil } defer { freeifaddrs(first) } + // IPv6 地址标志查询共用一个 DGRAM socket;打开失败只失去标志事实 + // (nil = 不过滤、不注记),不阻断枚举。 + let flagsSocket = socket(AF_INET6, SOCK_DGRAM, 0) + defer { + if flagsSocket >= 0 { + close(flagsSocket) + } + } + // 按首次出现顺序逐接口累积;同一接口的多个地址条目归并到同一事实。 var order: [String] = [] var loopbackByBSDName: [String: Bool] = [:] @@ -78,7 +87,7 @@ final class SystemInterfaceFactsProvider: LocalInterfaceFactsReading { order.append(bsdName) loopbackByBSDName[bsdName] = flags & IFF_LOOPBACK == IFF_LOOPBACK } - if let address = interfaceAddress(socketAddress) { + if let address = interfaceAddress(socketAddress, flagsSocket: flagsSocket, bsdName: bsdName) { addressesByBSDName[bsdName, default: []].append(address) } } @@ -97,7 +106,7 @@ final class SystemInterfaceFactsProvider: LocalInterfaceFactsReading { /// 单个地址条目的族与链路本地判定;非 IPv4/IPv6 条目(链路层等)或无法 /// 规范化的地址为 nil。 private static func interfaceAddress( - _ socketAddress: UnsafeMutablePointer + _ socketAddress: UnsafeMutablePointer, flagsSocket: Int32, bsdName: String ) -> LocalInterfaceAddress? { switch socketAddress.pointee.sa_family { case sa_family_t(AF_INET): @@ -105,21 +114,56 @@ final class SystemInterfaceFactsProvider: LocalInterfaceFactsReading { $0.pointee } guard let text = addressText(family: AF_INET, address: socket.sin_addr) else { return nil } - return LocalInterfaceAddress(address: text, family: .ipv4, isIPv6LinkLocal: false) + return LocalInterfaceAddress( + address: text, family: .ipv4, isIPv6LinkLocal: false, v6Flags: nil) case sa_family_t(AF_INET6): let socket = socketAddress.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee } guard let text = addressText(family: AF_INET6, address: socket.sin6_addr) else { return nil } + let v6Flags = + flagsSocket >= 0 + ? v6AddressFlags(flagsSocket: flagsSocket, bsdName: bsdName, address: socketAddress) + : nil return LocalInterfaceAddress( address: text, family: .ipv6, - isIPv6LinkLocal: isIPv6LinkLocal(socket.sin6_addr)) + isIPv6LinkLocal: isIPv6LinkLocal(socket.sin6_addr), + v6Flags: v6Flags) default: return nil } } + /// `SIOCGIFAFLAG_IN6` 逐地址查询内核 IPv6 标志(与 ifconfig 的 + /// 「autoconf secured / autoconf temporary / dynamic」注记同源)。查询 + /// 失败返回 nil。 + private static func v6AddressFlags( + flagsSocket: Int32, bsdName: String, address: UnsafeMutablePointer + ) -> LocalInterfaceV6Flags? { + var request = in6_ifreq() + // _IOWR('i', 73, struct in6_ifreq) 手工展开:Darwin 模块不导出带结构体 + // 参数的 ioctl 请求宏。 + let requestCode = + UInt32(IOC_INOUT) + | (UInt32(MemoryLayout.size) & UInt32(IOCPARM_MASK)) << 16 + | UInt32(0x69) << 8 | 73 + withUnsafeMutableBytes(of: &request.ifr_name) { nameBuffer in + _ = strlcpy( + nameBuffer.baseAddress!.assumingMemoryBound(to: CChar.self), bsdName, Int(IFNAMSIZ)) + } + address.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { sin6 in + request.ifr_ifru.ifru_addr = sin6.pointee + } + guard ioctl(flagsSocket, UInt(requestCode), &request) == 0 else { return nil } + // ifru_flags 与 ifru_addr 在联合上重叠:内核回写在偏移 IFNAMSIZ 的首 + // 4 字节,直接按位读取(不依赖 Swift 对 C 联合成员的重叠读写语义)。 + let rawFlags = withUnsafeBytes(of: &request) { + $0.loadUnaligned(fromByteOffset: Int(IFNAMSIZ), as: Int32.self) + } + return LocalInterfaceV6Flags(rawValue: rawFlags) + } + /// inet_ntop 规范化呈现形;转换失败不产生候选地址。 private static func addressText(family: Int32, address: T) -> String? { var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) diff --git a/ShadowsocksX-NG2/App/ProxyControlWorkflow/TerminalCommandAddress.swift b/ShadowsocksX-NG2/App/ProxyControlWorkflow/TerminalCommandAddress.swift index 9b043383..390da0bf 100644 --- a/ShadowsocksX-NG2/App/ProxyControlWorkflow/TerminalCommandAddress.swift +++ b/ShadowsocksX-NG2/App/ProxyControlWorkflow/TerminalCommandAddress.swift @@ -4,23 +4,36 @@ import SystemConfiguration // MARK: - 命令地址投影(issue #72) /// 首页命令地址候选(CONTEXT.md「Terminal command address」)。身份 = BSD -/// 接口身份 + 规范化 IP;本地化名称仅用于呈现,不参与身份。`Hashable` 供 -/// SwiftUI Picker 标签匹配使用,选择校验一律走 `identity`。 +/// 接口身份 + 规范化 IP;显示名与类型注记仅用于呈现,不参与身份。`Hashable` +/// 供 SwiftUI Picker 标签匹配使用,选择校验一律走 `identity`。 struct TerminalCommandAddress: Equatable, Hashable, Sendable { /// BSD 接口名;回环固定为 lo0。 let bsdName: String /// 规范化 IP 文本。 let address: String - /// `{IP} - {name}` 中的名称:系统本地化接口名称,缺失回退 BSD 名。 + /// 系统本地化接口名称,缺失回退 BSD 名;回环显示 lo0。 let displayName: String + /// IPv6 地址类型注记(与 ifconfig 同源:autoconf secured / autoconf / + /// dynamic);IPv4 或标志缺失为 nil。 + let annotation: String? - /// 跨刷新匹配既有选择的身份(不含显示名;显示名变化不使选择失效)。 + init(bsdName: String, address: String, displayName: String, annotation: String? = nil) { + self.bsdName = bsdName + self.address = address + self.displayName = displayName + self.annotation = annotation + } + + /// 跨刷新匹配既有选择的身份(不含显示名与注记;两者变化不使选择失效)。 var identity: TerminalCommandAddressIdentity { TerminalCommandAddressIdentity(bsdName: bsdName, address: address) } - /// 候选标签:`{IP} - {name}`。 - var label: String { "\(address) - \(displayName)" } + /// 候选标签:`{IP} - {name}`;带类型注记的 IPv6 为 `{IP} ({注记}) - {name}`。 + var label: String { + let addressPart = annotation.map { "\(address) (\($0))" } ?? address + return "\(addressPart) - \(displayName)" + } } /// 命令地址身份:BSD 接口名 + 规范化 IP。 @@ -88,7 +101,7 @@ enum TerminalCommandAddressPolicy { /// 当前监听方式的默认回环地址(Domain 回环清单首项)。 static func defaultLoopback(for mode: ListenerMode) -> TerminalCommandAddress { TerminalCommandAddress( - bsdName: "lo0", address: mode.proxyLoopbackAddress, displayName: "lo0") + bsdName: "lo0", address: mode.proxyLoopbackAddress, displayName: "lo0", annotation: nil) } // MARK: - 派生细节 @@ -96,7 +109,7 @@ enum TerminalCommandAddressPolicy { /// 回环候选(置顶)直接来自 Domain 的监听方式回环清单,展示名固定 lo0。 private static func loopbackCandidates(for mode: ListenerMode) -> [TerminalCommandAddress] { mode.compatibleLoopbackAddresses.map { - TerminalCommandAddress(bsdName: "lo0", address: $0, displayName: "lo0") + TerminalCommandAddress(bsdName: "lo0", address: $0, displayName: "lo0", annotation: nil) } } @@ -130,7 +143,11 @@ enum TerminalCommandAddressPolicy { return lhs.address < rhs.address } .map { - TerminalCommandAddress(bsdName: interface.bsdName, address: $0.address, displayName: name) + TerminalCommandAddress( + bsdName: interface.bsdName, + address: $0.address, + displayName: name, + annotation: annotation(for: $0)) } if !addresses.isEmpty { groups.append(InterfaceGroup(name: name, bsdName: interface.bsdName, addresses: addresses)) @@ -154,6 +171,8 @@ enum TerminalCommandAddressPolicy { guard !wildcardAddresses.contains(address.address) else { return false } // IPv6 链路本地需要额外接口作用域,不作为命令目标;IPv4 链路本地允许。 if address.family == .ipv6, address.isIPv6LinkLocal { return false } + // RFC 4941 隐私临时地址会轮换,不适合作为其他设备长期使用的命令目标。 + if address.v6Flags?.contains(.temporary) == true { return false } switch (mode, address.family) { case (.allIPv4Interfaces, .ipv4): return true @@ -165,4 +184,18 @@ enum TerminalCommandAddressPolicy { return false } } + + /// IPv6 地址类型注记(与 ifconfig 同源的打印逻辑);无标志(标志查询 + /// 失败或纯回环)不注记。 + private static func annotation(for address: LocalInterfaceAddress) -> String? { + guard let flags = address.v6Flags else { return nil } + if flags.contains(.autoconf) { + if flags.contains(.secured) { return "autoconf secured" } + if flags.contains(.temporary) { return "autoconf temporary" } + return "autoconf" + } + if flags.contains(.secured) { return "secured" } + if flags.contains(.dynamic) { return "dynamic" } + return nil + } } diff --git a/ShadowsocksX-NG2/Tests/FakeLocalInterfaceFacts.swift b/ShadowsocksX-NG2/Tests/FakeLocalInterfaceFacts.swift index ba27335e..5f1dedf8 100644 --- a/ShadowsocksX-NG2/Tests/FakeLocalInterfaceFacts.swift +++ b/ShadowsocksX-NG2/Tests/FakeLocalInterfaceFacts.swift @@ -6,11 +6,13 @@ import Foundation /// 测试专用地址便捷构造(数组字面量内以 `.ipv4("…")` 使用)。 extension LocalInterfaceAddress { static func ipv4(_ text: String) -> LocalInterfaceAddress { - LocalInterfaceAddress(address: text, family: .ipv4, isIPv6LinkLocal: false) + LocalInterfaceAddress(address: text, family: .ipv4, isIPv6LinkLocal: false, v6Flags: nil) } - static func ipv6(_ text: String, linkLocal: Bool = false) -> LocalInterfaceAddress { - LocalInterfaceAddress(address: text, family: .ipv6, isIPv6LinkLocal: linkLocal) + static func ipv6( + _ text: String, linkLocal: Bool = false, flags: LocalInterfaceV6Flags? = nil + ) -> LocalInterfaceAddress { + LocalInterfaceAddress(address: text, family: .ipv6, isIPv6LinkLocal: linkLocal, v6Flags: flags) } } diff --git a/ShadowsocksX-NG2/Tests/ProxyCommandAddressWorkflowTests.swift b/ShadowsocksX-NG2/Tests/ProxyCommandAddressWorkflowTests.swift index a130e483..68c324cd 100644 --- a/ShadowsocksX-NG2/Tests/ProxyCommandAddressWorkflowTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyCommandAddressWorkflowTests.swift @@ -85,6 +85,33 @@ final class ProxyCommandAddressWorkflowTests: XCTestCase { XCTAssertEqual(picker.selected.address, "127.0.0.1") } + func testTemporaryIPv6ExcludedAndAnnotationsInLabelMiddle() { + setListenerMode(.allIPv4AndIPv6Interfaces) + interfaceFacts.interfaces = [ + FakeLocalInterfaceFacts.wifi( + addresses: [ + .ipv6("240e::8150", flags: [.autoconf, .secured]), + .ipv6("240e::855", flags: [.autoconf, .temporary]), + .ipv6("240e::17", flags: [.dynamic]), + ]) + ] + interfaceFacts.emitChange() + + let picker = workflow.snapshot.commandAddressPicker + XCTAssertEqual( + picker.candidates.map(\.address), + ["127.0.0.1", "::1", "240e::17", "240e::8150"], + "RFC 4941 隐私临时地址被过滤(会轮换);其余按地址稳定排序") + XCTAssertEqual( + picker.candidates.first { $0.address == "240e::8150" }?.label, + "240e::8150 (autoconf secured) - Wi-Fi", + "注记位于标签中间,紧贴其描述的 IP") + XCTAssertEqual( + picker.candidates.first { $0.address == "240e::17" }?.label, + "240e::17 (dynamic) - Wi-Fi", + "DHCPv6 地址注记 dynamic") + } + func testIPv6OnlyModeDefaultsToV1AndFiltersFamilies() { setListenerMode(.allIPv6Interfaces) interfaceFacts.interfaces = [ @@ -208,20 +235,22 @@ final class ProxyCommandAddressLifecycleTests: XCTestCase { func testIPv6SelectionProducesBracketedURLs() { setListenerMode(.allIPv4AndIPv6Interfaces) interfaceFacts.interfaces = [ - FakeLocalInterfaceFacts.wifi(addresses: [.ipv6("2001:db8::1")]) + FakeLocalInterfaceFacts.wifi( + addresses: [.ipv6("2001:db8::1", flags: [.autoconf, .secured])]) ] interfaceFacts.emitChange() let v6Candidate = workflow.snapshot.commandAddressPicker.candidates[2] _ = workflow.selectCommandAddress(v6Candidate) + let commands = workflow.snapshot.terminalProxyEnvironmentCommands XCTAssertTrue( - workflow.snapshot.terminalProxyEnvironmentCommands.zshBash - .contains("export http_proxy='http://[2001:db8::1]:11087'"), + commands.zshBash.contains("export http_proxy='http://[2001:db8::1]:11087'"), "IPv6 URL 使用方括号与已保存 HTTP 端口") XCTAssertTrue( - workflow.snapshot.terminalProxyEnvironmentCommands.fish - .contains("set -gx all_proxy 'socks5://[2001:db8::1]:11086'")) + commands.fish.contains("set -gx all_proxy 'socks5://[2001:db8::1]:11086'")) + XCTAssertFalse( + commands.zshBash.contains("autoconf"), "类型注记只进标签,不进命令") } func testSelectionSurvivesLeavingAndReenteringHome() { diff --git a/ShadowsocksX-NG2/Tests/SystemInterfaceFactsProviderTests.swift b/ShadowsocksX-NG2/Tests/SystemInterfaceFactsProviderTests.swift index 9a3d6e61..89b81493 100644 --- a/ShadowsocksX-NG2/Tests/SystemInterfaceFactsProviderTests.swift +++ b/ShadowsocksX-NG2/Tests/SystemInterfaceFactsProviderTests.swift @@ -34,7 +34,8 @@ final class SystemInterfaceFactsProviderTests: XCTestCase { XCTAssertNil(lo0.interfaceType, "回环无 SystemConfiguration 类型映射") XCTAssertTrue( lo0.addresses.contains( - LocalInterfaceAddress(address: "127.0.0.1", family: .ipv4, isIPv6LinkLocal: false)), + LocalInterfaceAddress( + address: "127.0.0.1", family: .ipv4, isIPv6LinkLocal: false, v6Flags: nil)), "回环单播含规范化 127.0.0.1") } @@ -56,10 +57,16 @@ final class SystemInterfaceFactsProviderTests: XCTestCase { XCTAssertFalse( address.address.contains("%"), "\(interface.bsdName) 地址不含作用域后缀:\(address.address)") - if address.family == .ipv6, address.address.hasPrefix("fe80") { - XCTAssertTrue(address.isIPv6LinkLocal, "fe80 地址必须标记链路本地") - } else if address.family == .ipv6 { - XCTAssertFalse(address.isIPv6LinkLocal, "非 fe80 地址不应标记链路本地") + if address.family == .ipv6 { + XCTAssertNotNil( + address.v6Flags, "\(interface.bsdName) IPv6 地址标志查询不应失败") + if address.address.hasPrefix("fe80") { + XCTAssertTrue(address.isIPv6LinkLocal, "fe80 地址必须标记链路本地") + } else { + XCTAssertFalse(address.isIPv6LinkLocal, "非 fe80 地址不应标记链路本地") + } + } else { + XCTAssertNil(address.v6Flags, "IPv4 地址不携带 IPv6 标志") } } } @@ -75,12 +82,21 @@ final class SystemInterfaceFactsProviderTests: XCTestCase { let nonLoopback = candidates.dropFirst(2) XCTAssertFalse(nonLoopback.isEmpty, "真实主机存在可列出的接口地址候选") for candidate in nonLoopback { + let fact = interfaces.first { $0.bsdName == candidate.bsdName }? + .addresses.first { $0.address == candidate.address } let type = interfaces.first { $0.bsdName == candidate.bsdName }?.interfaceType XCTAssertTrue( type == kSCNetworkInterfaceTypeIEEE80211 as String || type == kSCNetworkInterfaceTypeEthernet as String, "候选 \(candidate.label) 必须来自允许类型接口(实际类型 \(type ?? "nil"))") XCTAssertFalse(candidate.address.contains("%"), "候选地址已规范化") + XCTAssertFalse( + fact?.v6Flags?.contains(.temporary) ?? false, + "临时地址(会轮换)不进候选:\(candidate.label)") + let knownAnnotations: Set = ["autoconf secured", "autoconf", "dynamic", nil] + XCTAssertTrue( + knownAnnotations.contains(candidate.annotation), + "IPv6 候选注记只能来自 ifconfig 同源闭集:\(candidate.label)") } } } diff --git a/docs/design/terminal-command-address.md b/docs/design/terminal-command-address.md index 9f0e3962..263aff95 100644 --- a/docs/design/terminal-command-address.md +++ b/docs/design/terminal-command-address.md @@ -13,7 +13,7 @@ 仅包含 loopback,以及本机当前启用的 Wi-Fi、Ethernet 接口的单播 IP。VPN、其他虚拟接口和其他接口类型排除;不能仅依据 BSD 接口前缀或通用 Ethernet 链路类型来接受虚拟接口。无法确认是允许类型的非回环接口不纳入列表。 -不提供手动输入,不提供通配绑定地址,IPv6 链路本地地址排除。IPv4 链路本地地址属于允许接口的单播地址时可以列出。候选列表不表示网络连通性已验证。 +不提供手动输入,不提供通配绑定地址,IPv6 链路本地地址排除;IPv6 地址经 `SIOCGIFAFLAG_IN6` 查询内核标志,`IN6_IFF_TEMPORARY`(RFC 4941 隐私临时地址,会轮换)不进候选。IPv4 链路本地地址属于允许接口的单播地址时可以列出。候选列表不表示网络连通性已验证。 | 已保存的监听方式 | 地址族 | 默认地址 | | --- | --- | --- | @@ -22,7 +22,7 @@ | 所有 IPv4 与 IPv6 接口 | IPv4、IPv6,包含两个回环地址 | 127.0.0.1 | | 仅所有 IPv6 接口 | IPv6 | ::1 | -每项显示 `{IP} - {name}`。接口名称优先取 `SCNetworkInterfaceGetLocalizedDisplayName(interface)`;名称缺失或无法映射到 SystemConfiguration 接口时,回退到 BSD 接口名,但不会因此放宽接口类型过滤。回环项回退名称为 lo0。 +每项显示 `{IP} - {name}`;带内核标志的 IPv6 地址在 IP 后附与 ifconfig 同源的类型注记,标签为 `{IP} ({注记}) - {name}`(如 `autoconf secured`、`dynamic`),便于识别地址稳定性。接口名称优先取 `SCNetworkInterfaceGetLocalizedDisplayName(interface)`;名称缺失或无法映射到 SystemConfiguration 接口时,回退到 BSD 接口名,但不会因此放宽接口类型过滤。回环项回退名称为 lo0。 回环项置顶,双栈时 127.0.0.1 在 ::1 前。其余按接口名称排序,同一接口先 IPv4 后 IPv6,再按地址稳定排序。同一接口的重复 IP 合并;不同接口即使 IP 相同也保留接口身份。