import Foundation /// The macOS 15+ Local Network subnet allowlist: where it lives, what counts as /// covering the guest range, and the commands that write it. /// /// ## Why an allowlist at all /// /// Local Network privacy is not TCC. It is a Network Extension packet filter, /// so there is no database to query, `tccutil` does not apply, and a blocked /// flow is not reported as "denied" — it comes back `EHOSTUNREACH` (errno 65, /// "No route to host"), indistinguishable from a guest that is genuinely off /// the network (Apple, TN3179). /// /// Worse, nothing here is well placed to *answer* the prompt. A LaunchAgent has /// no UI to show it in, and a run started from a shell is attributed to the /// **responsible process** — Terminal — so both the prompt and the System /// Settings row belong to Terminal, and granting it there does not carry over /// to the agent. /// /// The allowlist sidesteps all of that: it is consulted before the per-app /// check, so a flow to a listed subnet is never subject to a prompt, by any /// process. Its one cost is that the values are read at boot, so setting it /// requires a reboot to take effect. That is the trade this type exists to make /// explicit. /// /// Everything here is pure — reading a plist and formatting argument vectors — /// so it lives in `RunnerCore` and is unit-tested. The effectful half (running /// `sudo`, launching the app to trigger a prompt) is `RunnerHost`'s /// `LocalNetworkPermission`. public enum LocalNetworkPolicy { // MARK: - Where the setting lives /// The preferences domain macOS reads the allowlist from. public static let domain = "com.apple.network.local-network" /// The wired interfaces key. public static let ethernetKey = "AllowedEthernetLocalNetworkAddresses" /// The Wi-Fi interfaces key. public static let wifiKey = "AllowedWiFiLocalNetworkAddresses" /// Both keys. Guests are reached over a virtual interface, and which of the /// two the filter consults is not something we get to observe — so both are /// always written, and both are read back. public static let keys = [ethernetKey, wifiKey] /// Every preferences file the allowlist could plausibly be written to. /// /// The domain is written with `sudo`, so which preferences directory it /// lands in depends on whether that `sudo` preserved `HOME`. Rather than /// guess at the host's sudoers configuration, check each candidate. /// /// In practice the first candidate is where it lands, and `/var/root` is /// mode 700 — so an unprivileged process cannot read back what it just /// wrote. That is what ``Status/unreadablePaths`` exists to report, and /// what `RunnerHost`'s `LocalNetworkPermission.observedStatus()` works /// around by re-reading as root. public static func preferenceCandidates() -> [String] { [ "/var/root/Library/Preferences/\(domain).plist", "/Library/Preferences/\(domain).plist", NSHomeDirectory() + "/Library/Preferences/\(domain).plist", ] } // MARK: - What to write /// The subnets granted by default: all of RFC 1918. /// /// Deliberately wider than the `192.168.64.0/18` that vmnet actually uses. /// The allowlist is read at boot, so getting it wrong costs a reboot to fix, /// and the failure mode of "too narrow" is silent — guests simply stop being /// reachable the day the host joins a network that shifts things around. /// This is also the set Tart, orchard, and packer-plugin-tart ship, so a /// host already configured for one of those needs no second entry. /// /// Narrow it with `permissions grant --subnet` on a host where that matters. public static let defaultSubnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] /// The argument vectors that write `subnets` to both keys. /// /// Returned as arrays, not a shell string: these are handed straight to /// `/usr/bin/defaults` with no shell in between, so a subnet containing /// something shell-significant cannot become an injection. /// /// - Parameter subnets: CIDR entries, e.g. `["192.168.0.0/16"]`. /// - Returns: One `defaults write …` argument vector per key. public static func writeArguments(subnets: [String]) -> [[String]] { keys.map { key in ["write", domain, key, "-array"] + subnets } } /// The same commands as copy-pasteable shell, for the message shown when we /// cannot run them ourselves. public static func writeCommandLines(subnets: [String]) -> [String] { writeArguments(subnets: subnets).map { arguments in "sudo defaults " + arguments.map(quoteForShell).joined(separator: " ") } } /// Single-quotes an argument unless it is plainly inert. private static func quoteForShell(_ argument: String) -> String { let safe = argument.allSatisfy { $0.isLetter || $0.isNumber || "./_-".contains($0) } guard !safe || argument.isEmpty else { return argument } return "'" + argument.replacingOccurrences(of: "'", with: #"'\''"#) + "'" } // MARK: - Reading it back /// What the host's allowlist currently says. public struct Status: Sendable, Equatable { /// Every distinct entry found, across both keys and all candidate files. public let allowlist: [String] /// The files the entries came from, in the order they were checked. public let sourcePaths: [String] /// Whether at least one entry covers the whole vmnet range. public let coversGuestRange: Bool /// Candidate files this process was refused permission to read. /// /// Not the same as "absent". `sudo defaults write` normally lands in /// `/var/root/Library/Preferences`, which is mode 700, so an ordinary /// user is refused before it can find out whether the file is even /// there. An empty ``allowlist`` with a non-empty `unreadablePaths` /// means *unknown*, not *unconfigured*, and must not be reported as /// the latter. public let unreadablePaths: [String] /// Whether anything is configured at all. public var isConfigured: Bool { !allowlist.isEmpty } /// Whether nothing was found and something could not be read, so the /// answer is genuinely unknown without administrator rights. public var isIndeterminate: Bool { allowlist.isEmpty && !unreadablePaths.isEmpty } public init( allowlist: [String], sourcePaths: [String], coversGuestRange: Bool, unreadablePaths: [String] = [] ) { self.allowlist = allowlist self.sourcePaths = sourcePaths self.coversGuestRange = coversGuestRange self.unreadablePaths = unreadablePaths } } /// Reads the host's current allowlist with this process's own privileges. /// /// Best effort and never fatal: an absent preferences file reads as "no /// allowlist", and one that exists but cannot be opened is recorded in /// ``Status/unreadablePaths`` rather than being mistaken for absent. public static func status() -> Status { var sources: [(path: String, data: Data)] = [] var unreadable: [String] = [] for path in preferenceCandidates() { if let data = FileManager.default.contents(atPath: path) { sources.append((path, data)) } else if access(path, R_OK) != 0, errno == EACCES { // Refused, not missing — including when the refusal is on a // parent directory, which is exactly the /var/root case. unreadable.append(path) } } return status(fromContentsOf: sources, unreadablePaths: unreadable) } /// Builds a ``Status`` from preferences files already read, however they /// were obtained. /// /// Split out from ``status()`` so the privileged read-back in `RunnerHost` /// — which has to shell out to `sudo` to see root's copy — shares this /// parsing rather than reimplementing it. public static func status( fromContentsOf sources: [(path: String, data: Data)], unreadablePaths: [String] = [] ) -> Status { var found: [String] = [] var paths: [String] = [] for source in sources { let fresh = entries(inPreferences: source.data).filter { !found.contains($0) } guard !fresh.isEmpty else { continue } found.append(contentsOf: fresh) paths.append(source.path) } return Status( allowlist: found, sourcePaths: paths, coversGuestRange: found.contains(where: coversVMNetRange), unreadablePaths: unreadablePaths ) } /// Every allowlist entry in one preferences file, across both keys, in the /// order encountered and without duplicates. Unparseable data reads empty. public static func entries(inPreferences data: Data) -> [String] { guard let plist = try? PropertyListSerialization.propertyList( from: data, options: [], format: nil) as? [String: Any] else { return [] } var found: [String] = [] for key in keys { for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) { found.append(entry) } } return found } /// Subnets pre-authorized for local network access on this host, if any. public static func allowlist() -> [String] { status().allowlist } // MARK: - Coverage arithmetic /// The span of addresses a vmnet NAT link can plausibly use. /// /// `192.168.64.0/24` is only the *first* choice: the subnet is picked at /// runtime and steps to the next free /24 when that one is already in use, /// which is why a host that worked yesterday can hand out `192.168.65.x` /// today. Everything from 192.168.64.0 to 192.168.127.255 — a /18 — is /// treated as guest territory so the allowlist survives that drift. public static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0 public static let vmNetLastAddress: UInt32 = 0xC0A8_7FFF // 192.168.127.255 /// Whether one allowlist entry covers the whole guest range. /// /// Deliberately all-or-nothing: partial cover is the failure mode being /// warned about, so an entry that contains today's subnet but not /// tomorrow's is not treated as good enough. public static func coversVMNetRange(_ entry: String) -> Bool { guard let (network, broadcast) = range(of: entry) else { return false } return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress } /// Whether `entry` is a well-formed IPv4 address or CIDR block. /// /// Used to reject `--subnet` typos at parse time. An entry macOS cannot /// understand is silently ignored by the filter, which would leave the /// operator with a configured-looking allowlist that grants nothing. public static func isValidSubnet(_ entry: String) -> Bool { range(of: entry) != nil } /// The first and last address of an IPv4 CIDR entry; nil for anything that /// is not one (an IPv6 entry, a hostname, a typo). /// /// A bare address is treated as a /32, matching `defaults`' own reading. static func range(of entry: String) -> (network: UInt32, broadcast: UInt32)? { // Empty components are kept, so a trailing slash is a parse failure // rather than silently reading "192.168.64.0/" as a bare /32 host. let parts = entry.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) guard let first = parts.first, let base = ipv4Value(String(first)) else { return nil } let prefix = parts.count == 2 ? Int(parts[1]) : 32 guard let prefix, (0...32).contains(prefix) else { return nil } let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix) let network = base & mask return (network, network | ~mask) } /// Packs dotted-quad IPv4 into a comparable integer; nil for anything else /// (an IPv6 entry, a hostname, a typo). public static func ipv4Value(_ text: String) -> UInt32? { let octets = text.split(separator: ".", omittingEmptySubsequences: false) guard octets.count == 4 else { return nil } var value: UInt32 = 0 for octet in octets { guard let number = UInt32(octet), number <= 255 else { return nil } value = value << 8 | number } return value } }