Merge nucleic/vivid-glass-urchin-xoym into main
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
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.
|
||||
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
|
||||
|
||||
/// Whether anything is configured at all.
|
||||
public var isConfigured: Bool { !allowlist.isEmpty }
|
||||
|
||||
public init(allowlist: [String], sourcePaths: [String], coversGuestRange: Bool) {
|
||||
self.allowlist = allowlist
|
||||
self.sourcePaths = sourcePaths
|
||||
self.coversGuestRange = coversGuestRange
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the host's current allowlist.
|
||||
///
|
||||
/// Best effort and never fatal: an unreadable or absent preferences file
|
||||
/// simply reads as "no allowlist".
|
||||
public static func status() -> Status {
|
||||
var found: [String] = []
|
||||
var sources: [String] = []
|
||||
|
||||
for path in preferenceCandidates() {
|
||||
guard let data = FileManager.default.contents(atPath: path),
|
||||
let plist = try? PropertyListSerialization.propertyList(
|
||||
from: data, options: [], format: nil) as? [String: Any]
|
||||
else { continue }
|
||||
|
||||
var contributed = false
|
||||
for key in keys {
|
||||
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
|
||||
found.append(entry)
|
||||
contributed = true
|
||||
}
|
||||
}
|
||||
if contributed { sources.append(path) }
|
||||
}
|
||||
|
||||
return Status(
|
||||
allowlist: found,
|
||||
sourcePaths: sources,
|
||||
coversGuestRange: found.contains(where: coversVMNetRange)
|
||||
)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user