281 lines
12 KiB
Swift
281 lines
12 KiB
Swift
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
|
|
}
|
|
}
|