feat(app): annotate and filter IPv6 command addresses by kernel flags for #72

经 SIOCGIFAFLAG_IN6 逐地址查询内核 IPv6 标志(与 ifconfig 的注记同源,
Darwin 模块不导出该 ioctl 请求宏,手工展开 _IOWR 编码):

- IN6_IFF_TEMPORARY(RFC 4941 隐私临时地址,会轮换)不进候选
- 其余 IPv6 候选在标签 IP 后附类型注记(位于中段):
  autoconf secured / autoconf / dynamic;标志查询失败不注记也不过滤
- 注记只进标签不进命令;地址身份仍为 BSD 接口名 + 规范化 IP

真实主机非视觉验证扩展:每 IPv6 地址标志查询非空、候选注记属 ifconfig
同源闭集、临时地址被过滤;替身用例覆盖 temporary 过滤与标签格式。
This commit is contained in:
Qiu Yuzhou
2026-10-01 15:19:26 +08:00
parent d647a4a859
commit 111fa93131
8 changed files with 172 additions and 31 deletions
+1 -1
View File
@@ -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.
@@ -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):系统适配器逐接口提供,类型白名单、
@@ -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<sockaddr>
_ socketAddress: UnsafeMutablePointer<sockaddr>, 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<sockaddr>
) -> LocalInterfaceV6Flags? {
var request = in6_ifreq()
// _IOWR('i', 73, struct in6_ifreq) 手工展开:Darwin 模块不导出带结构体
// 参数的 ioctl 请求宏。
let requestCode =
UInt32(IOC_INOUT)
| (UInt32(MemoryLayout<in6_ifreq>.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<T>(family: Int32, address: T) -> String? {
var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
@@ -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
}
}
@@ -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)
}
}
@@ -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() {
@@ -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<String?> = ["autoconf secured", "autoconf", "dynamic", nil]
XCTAssertTrue(
knownAnnotations.contains(candidate.annotation),
"IPv6 候选注记只能来自 ifconfig 同源闭集:\(candidate.label)")
}
}
}
+2 -2
View File
@@ -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 相同也保留接口身份。