Files
gitea-macos-vm-orchestrator/Sources/RunnerCore/LocalNetworkPolicy.swift
T
2026-08-08 17:39:40 -07:00

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
}
}